crud

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

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 — error mapping, cursor encoding/decoding, pagination clamping, list bookkeeping — moves into this package.

Scoping seams

This package resolves no principal and injects no WHERE clause: who a caller is, and which rows are theirs, is application policy forge does not own. What it does own is whether the shapes can EXPRESS that policy, and every request-projecting closure therefore takes a context.Context, because the caller's claims live there:

Before this, four of the five shapes could not see a context at all, so scoping a delegated RPC meant abandoning the delegation and hand-writing the lifecycle. That cost is what made a declared ownership boundary (schemadef's forge:owner) expensive enough to be worth deleting rather than satisfying.

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

Constants

View Source
const (
	// ReasonNotFound — the addressed row does not exist (or is
	// soft-deleted). Wire code: NotFound.
	ReasonNotFound = "not_found"

	// ReasonDuplicate — the write collided with a uniqueness constraint:
	// some value the caller supplied is already taken. The message names
	// the constraint the write violated (see pgFailure.identity), which is
	// what tells a person which value to change; a form that wants
	// per-field placement still routes on this code, not on the name.
	// Wire code: AlreadyExists.
	ReasonDuplicate = "duplicate"

	// ReasonReferenceMissing — a create/update pointed at a related
	// record that does not exist. The caller's move is to pick a valid
	// reference. Wire code: FailedPrecondition.
	ReasonReferenceMissing = "reference_missing"

	// ReasonReferenceInUse — a delete was refused because other records
	// still reference this one. The caller's move is to remove the
	// dependents first — a materially different remedy from
	// ReasonReferenceMissing, which is why the two are separate codes
	// even though postgres reports both as SQLSTATE 23503.
	// Wire code: FailedPrecondition.
	ReasonReferenceInUse = "reference_in_use"

	// ReasonRequiredFieldMissing — a required value was absent: a NOT
	// NULL column with no value, or a request message missing the entity
	// it is supposed to carry. Wire code: InvalidArgument.
	ReasonRequiredFieldMissing = "required_field_missing"

	// ReasonConstraintViolated — a value was well-formed and present but
	// outside what the data model permits (a CHECK constraint). Distinct
	// from ReasonInvalidFormat, where the value could not even be parsed.
	// Wire code: InvalidArgument.
	ReasonConstraintViolated = "constraint_violated"

	// ReasonInvalidFormat — a value could not be parsed into its column's
	// type (a malformed UUID, a malformed JSON document).
	// Wire code: InvalidArgument.
	ReasonInvalidFormat = "invalid_format"

	// ReasonUnknownField — an update_mask path names a field the caller
	// may not update: not a column at all, a column the repository owns
	// (forge:version, GENERATED), or a field the wire declares read-only
	// (forge:read-only / forge:computed, see UpdateOp.ReadOnly). One code
	// for all three because the caller's move is the same — drop the path.
	// Wire code: InvalidArgument.
	ReasonUnknownField = "unknown_field"

	// ReasonVersionConflict — the row was modified by another writer
	// between this caller's read and its write, on an entity that declared
	// a `forge:version` column. The caller's move is specific and
	// mechanical, which is why this is not folded into any existing
	// reason: re-read the row, re-apply the change to the fresh values,
	// and write again. It is the one CRUD failure where a blind retry of
	// the SAME request is guaranteed to fail identically.
	// Wire code: Aborted.
	ReasonVersionConflict = "version_conflict"

	// ReasonInvalidPageToken — the supplied page_token did not decode.
	// The caller's move is to restart the listing from page one.
	// Wire code: InvalidArgument.
	ReasonInvalidPageToken = "invalid_page_token"

	// ReasonInvalidOrderBy — the supplied order_by names a column this
	// entity does not declare. Wire code: InvalidArgument.
	ReasonInvalidOrderBy = "invalid_order_by"

	// ReasonPageTokenOrderConflict — a page_token was combined with an
	// order_by that the keyset cursor cannot page. The caller's move is
	// to drop one of the two, which is neither "bad token" nor "bad
	// order" — hence its own code. Wire code: InvalidArgument.
	ReasonPageTokenOrderConflict = "page_token_order_conflict"

	// ReasonInternal — the server failed for a reason it did not
	// classify. Deliberately a single bucket: the client's only move is
	// to report and retry, so splitting it would encode server internals
	// into a client contract. It is also the code that keeps the
	// vocabulary total — no forge error reaches the wire without a
	// reason. Wire code: Internal.
	ReasonInternal = "internal"
)
  • Why they exist
  • Design rules these names follow

Reason codes: the machine-readable half of every error this package puts on the wire.

Why they exist

A Connect code and a human message are not enough to route a UI. The code is too coarse (three different CRUD failures all arrive as InvalidArgument) and the message is not a contract at all — it is display copy, and forge reserves the right to reword it. Left with only those two, callers reach for the one discriminator that appears to work: matching the prose. A real dogfood run produced exactly that, `text.includes("already exists")`, on a duplicate-email conflict, which is the failure mode forge's own frontend runtime documentation forbids (web-runtime/src/errors.ts: key off `error.reason`, never the message). The instruction was right and the header did not exist.

Every error returned by this package now carries one of the codes below as svcerr.ReasonHeader ("x-forge-error-reason") metadata, which connect-go merges into the HTTP response headers for unary calls and into the end-stream trailer for streaming ones. The frontend reads it as ConnectClientError.reason.

Design rules these names follow

  1. A reason names the CAUSE, not the Connect code. A reason that merely echoes the code ("already_exists" for CodeAlreadyExists) carries no information the client did not already have, and it is the name that must be SPLIT later when a second cause maps to the same code — breaking every caller keyed on it. Cause names never need splitting; new causes get new names.
  2. The vocabulary is TOTAL. Every error path here sets a reason, including the unclassified one (ReasonInternal). A frontend that switches on `error.reason` must never fall through to null, or it is back to sniffing prose for whatever the switch missed. This is the property the wire tests assert.
  3. Reasons are engine-neutral. They describe what the CALLER did wrong (or what the server could not do), not which SQLSTATE postgres returned. "duplicate" outlives a migration to a different database; "unique_violation" is postgres vocabulary leaking into a public contract. The violated constraint's NAME belongs in the MESSAGE, never in the reason: a reason is the stable thing a switch is written against, and constraint names move with migrations.
  4. Reasons distinguish only what a client can ACT on differently. Where the client's only move is "report it and retry", the reason is ReasonInternal, however the failure arose.

These are a public wire contract. Adding a code is cheap; changing the meaning of one is not.

Variables

View Source
var ErrEntityRequired = errors.New("crud: request carries no entity")

ErrEntityRequired is what an UpdateOp.Entity shim returns when the request carries no entity at all. HandleUpdate turns it into CodeInvalidArgument naming the field; every OTHER error from the same shim is a projection failure and turns into CodeInternal. One return value carries both because they are the same question — "did the projection produce an entity?" — and a separate `ok bool` alongside an error only invites the two to disagree.

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:

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 persist -> empty response.

A PK that matched no row is a NotFound, not an empty 200: the repository reports its not-found error (svcerr.NotFound, errors.Is-true for orm.ErrNoRows) and mapRepoErr maps it exactly as it does for Get. See crud.requireRowTouched for the reasoning and for the per-entity opt out of absent-is-an-error.

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 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 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 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).
  • a path named in op.ReadOnly → CodeInvalidArgument naming the path, refused before the entity is even built. Nothing is written.
  • unknown or immutable path → CodeInvalidArgument naming the path (mapped from orm.UnknownFieldError by mapRepoErr).

The response carries the entity as the repository left it. The generated repository reads back every column a write did not set (see Repo.Update), so on both paths the response reports the STORED value of a column the caller could not write — a read-only status, a GENERATED total — rather than echoing whatever the request carried for it.

func ScopedDelete

func ScopedDelete[Ent any](
	ctx context.Context,
	pkColumn string,
	id string,
	opts []orm.QueryOption,
	del func(ctx context.Context, id string) error,
	list func(ctx context.Context, opts ...orm.QueryOption) ([]Ent, error),
) error

ScopedDelete removes one row by primary key, refusing when opts exclude it. See the package-level note above on why the check and the write are two statements.

func ScopedGet

func ScopedGet[Ent any](
	ctx context.Context,
	pkColumn string,
	id string,
	opts []orm.QueryOption,
	get func(ctx context.Context, id string) (Ent, error),
	list func(ctx context.Context, opts ...orm.QueryOption) ([]Ent, error),
) (Ent, error)

ScopedGet resolves one row by primary key, narrowed by opts.

With no opts it delegates to get verbatim. With opts it runs a single SELECT carrying both the PK and the caller's predicate; a row that exists but does not satisfy the predicate reports the same not-found error an unknown id gets (svcerr.NotFound, errors.Is-true for orm.ErrNoRows), which HandleGet maps to NotFound — the same answer an unknown id gets.

func ScopedUpdate

func ScopedUpdate[Ent any](
	ctx context.Context,
	pkColumn string,
	id string,
	entity Ent,
	opts []orm.QueryOption,
	update func(ctx context.Context, entity Ent) error,
	list func(ctx context.Context, opts ...orm.QueryOption) ([]Ent, error),
) error

ScopedUpdate writes an entity by primary key, refusing when opts exclude the STORED row.

The predicate is evaluated against what is in the database, never against the submitted entity: a caller who rewrites the owner column in the request they send would otherwise pass a check made against their own submission and take ownership of someone else's row.

func ScopedUpdateMasked

func ScopedUpdateMasked[Ent any](
	ctx context.Context,
	pkColumn string,
	id string,
	entity Ent,
	fields []string,
	opts []orm.QueryOption,
	update func(ctx context.Context, entity Ent, fields []string) error,
	list func(ctx context.Context, opts ...orm.QueryOption) ([]Ent, error),
) error

ScopedUpdateMasked is ScopedUpdate for the AIP-134 masked write. The mask decides WHICH columns are written; opts decide WHETHER the stored row may be written at all, and the two are independent.

Types

type CreateOp

type CreateOp[Req, Resp, Ent any] struct {
	EntityLower string
	// Entity projects the request onto the internal entity. It returns an
	// error for the same reason Pack does, in the opposite direction: a
	// value the request CARRIED that the projection cannot encode (a
	// repeated message onto a jsonb column, say) must fail the call, not
	// store a default and report success. Shims that cannot fail return
	// (entity, nil).
	//
	// It takes ctx because stamping a new row's owner needs the CALLER,
	// and the caller lives on the context (see the package doc's note on
	// scoping seams). Generated shims that read nothing from it declare
	// `_ context.Context`.
	Entity  func(ctx context.Context, req *Req) (Ent, error)
	Persist func(ctx context.Context, entity Ent) error
	// Pack projects the persisted entity onto the response. It returns an
	// error so a corrupt-data projection (e.g. a generated <entity>ToProto
	// that read back an enum value name absent from the current proto)
	// surfaces as CodeInternal instead of silently shipping a degraded
	// message. Shims that cannot fail return (resp, nil).
	Pack func(entity Ent) (*Resp, error)
}

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.
  • Pack — internal entity -> connect.Response.
  • EntityLower — lowercase entity name used in the error envelope.

type DeleteOp

type DeleteOp[Req, Resp any] struct {
	EntityLower string
	ID          func(req *Req) string
	// Persist removes the row named by the primary key. See GetOp.Fetch
	// for why the opts are variadic and what belongs in them: a delete
	// scoped by a WHERE predicate touches no row when the row is not the
	// caller's, and the repository's absent-row check then reports
	// NotFound — the same answer an unknown id gets.
	Persist func(ctx context.Context, id string, opts ...orm.QueryOption) 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
	ID          func(req *Req) string
	// Fetch reads the row named by the primary key.
	//
	// The variadic opts carry an ADDITIONAL predicate the caller wants
	// applied in the query — canonically the ownership filter, e.g.
	// orm.WhereEq("company_id", claims.OrgID). It is variadic so an
	// override that has nothing to add passes nothing, and so the
	// predicate composes with whatever the repository already applies
	// (soft-delete scoping, and so on) rather than replacing it.
	//
	// Scoping in the QUERY is what makes "not yours" and "not there"
	// the same answer: a fetch-then-compare returns the row to the
	// handler first, and every branch after that is one an author has
	// to remember to write. HandleGet passes no opts of its own — this
	// parameter exists for the shim and for overrides.
	Fetch func(ctx context.Context, id string, opts ...orm.QueryOption) (Ent, error)
	// Pack projects the fetched entity onto the response; see CreateOp.Pack
	// for why it returns an error.
	Pack func(entity Ent) (*Resp, error)
}

GetOp wires the per-RPC concerns of a Get handler.

type ListOp

type ListOp[Req, Resp, Ent any] struct {
	EntityLower  string
	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(ctx context.Context, 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.
	//
	// It is also THE place a per-caller row scope belongs, which is why
	// it takes ctx: the ownership predicate is a WHERE clause like any
	// other, and it must be in the query rather than applied to the
	// returned page, because Count runs over these same opts and a
	// post-filtered page would report a total including rows the caller
	// cannot see.
	//
	// It returns an error so a closure that CANNOT determine the scope
	// can fail closed. Without one, the only way to say "I could not
	// resolve who is asking" is to return no filter — which silently
	// widens the query to every row, the exact failure this seam exists
	// to prevent. HandleList maps the error the way it maps Entity's, so
	// an svcerr sentinel keeps its code and a bare error becomes
	// Internal.
	Filters func(ctx context.Context, req *Req) ([]orm.QueryOption, error)

	// 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. Returns slice + error; the library
	// handles the +1 fetch and trim-to-pageSize.
	Query func(ctx context.Context, opts []orm.QueryOption) ([]Ent, error)

	// Count runs a COUNT over the SAME filters as Query (but WITHOUT the page
	// limit or cursor) to populate the response's total_count. It is wired
	// only when the response message carries a total_count field; nil
	// otherwise, in which case the library passes totalCount = 0 to Pack (the
	// pre-fix behavior). The opts it receives are the filter opts only.
	Count func(ctx context.Context, opts []orm.QueryOption) (int64, 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, the next page token (empty when
	// no further page), and the total row count matching the filters (0 when
	// Count is not wired). Shim assembles the response with the right
	// repeated-field name. It returns an error so a per-item projection
	// failure (see CreateOp.Pack) surfaces as CodeInternal.
	Pack func(items []Ent, nextPageToken string, totalCount int64) (*Resp, error)
}

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]() line.

All lifecycle semantics the pre-generic code carried are preserved exactly: 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

func NewRepo[M any]() *Repo[M]

NewRepo constructs a Repo for model M. Metadata derivation is deferred to the first call, which needs a live bun.IDB to reach the dialect's table cache; everything it needs comes off the model's own Bun tags.

func (*Repo[M]) Columns

func (r *Repo[M]) Columns(db orm.Context) []string

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, opts ...orm.QueryOption) (int64, error)

Count returns the number of matching rows under the same scope as List.

func (*Repo[M]) Create

func (r *Repo[M]) Create(ctx context.Context, db orm.Context, entity *M) error

Create inserts a new row. Plain INSERT, never an upsert: a duplicate PK is a real error. Chokepoint invariants:

  • a server-allocated PK (,autoincrement: the schema gives the key an identity or a DEFAULT) left at its zero is excluded from the INSERT and read back via RETURNING; a key the caller set is inserted as set;
  • any other string PK is ULID-generated when empty — never one the database allocates, whose DEFAULT (gen_random_uuid()) knows the column's type and a ULID does not;
  • managed timestamps are stamped; nil array fields are normalized to {}.

func (*Repo[M]) Delete

func (r *Repo[M]) Delete(ctx context.Context, db orm.Context, id any) error

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.

A PK that matched no live row returns the repo's not-found error (notFound), which pkg/crud maps to CodeNotFound — the same answer Get gives for the same id. See requireRowTouched for why that, and not a silent success.

func (*Repo[M]) DeleteByPK added in v1.0.0

func (r *Repo[M]) DeleteByPK(ctx context.Context, db orm.Context, key *M) error

DeleteByPK removes the row whose primary key equals key's — every key column, so it is how a COMPOSITE-key entity is deleted (Delete takes one id). Soft delete and the not-found answer are Delete's.

func (*Repo[M]) Get

func (r *Repo[M]) Get(ctx context.Context, db orm.Context, id any) (*M, error)

Get retrieves a row by primary key. A missing row is the repo's not-found error (notFound): svcerr.NotFound, and errors.Is-true for orm.ErrNoRows. The legacy-TEXT deleted_at filter is applied; Bun-native soft delete auto-excludes tombstones from the SELECT.

func (*Repo[M]) GetByPK added in v1.0.0

func (r *Repo[M]) GetByPK(ctx context.Context, db orm.Context, key *M) (*M, error)

GetByPK retrieves the row whose primary key equals key's — every key column, so it is how a COMPOSITE-key entity is read (Get takes one id). Only key's primary-key fields are read. A missing row is the repo's not-found error (notFound).

func (*Repo[M]) List

func (r *Repo[M]) List(ctx context.Context, db orm.Context, opts ...orm.QueryOption) ([]*M, error)

List retrieves rows with optional QueryOption filtering/ordering/limit. The legacy-TEXT soft-delete filter is applied; Bun's ,soft_delete excludes tombstones natively.

With no orm.WithLimit, List returns EVERY matching row. It used to cap a limit-less call at 10,000 rows, silently: a caller asking for all of a table's rows got the first 10,000 and no sign there were more, which is a wrong answer that only appears once the table grows. A query's answer must not depend on the size of the table it reads. Bounding a read is the caller's decision, made where the caller can act on it: orm.WithLimit for a page, and orm.WhereGt on the key with WithOrderBy + WithLimit to walk a table that grows without bound one page at a time. The paged List rpcs in this package always pass their own clamped page size (MaxPageSize), so the wire stays bounded.

func (*Repo[M]) ListAll

func (r *Repo[M]) ListAll(ctx context.Context, db orm.Context, opts ...orm.QueryOption) ([]*M, error)

ListAll retrieves rows INCLUDING soft-deleted ones. 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

func (r *Repo[M]) PkColumn(db orm.Context) string

PkColumn is the primary-key column name (the List handler's cursor column). Derived from Bun's schema.

func (*Repo[M]) Update

func (r *Repo[M]) Update(ctx context.Context, db orm.Context, entity *M, opts ...UpdateOption) error

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, deleted_at, and any column tagged ,skipupdate — the projection of a `forge:immutable` column declaration — are excluded from r.m.updatable and so never named in the SET clause. That is what keeps a maskless Update built from a round-tripped entity (one whose stripped or server-owned columns came back zero-valued) from writing those zeros over stored data; a new value is set via Create or a masked Update naming the column. 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.

An entity that declared a `forge:version` column additionally gets optimistic concurrency control: see applyVersionGuard.

Every column the SET clause does not name is read back into entity (see readBackUnwritten), so after a nil return the caller holds the row as stored — a held-back column reports its stored value, not whatever the caller's struct carried for it. NoReadBack turns that off for one call.

opts tune this one call; see Preserve and NoReadBack.

func (*Repo[M]) UpdateMasked

func (r *Repo[M]) UpdateMasked(ctx context.Context, db orm.Context, entity *M, fields []string, opts ...UpdateOption) 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.

A ,skipupdate column named by the mask is a deliberate assertion (rotating a secret, reassigning an owner) and must be written — the opposite of Update's full-replace rule. Bun's own SET-clause builder applies ,skipupdate unconditionally regardless of which path asked for the column, so such columns are pulled out of Column(...) and set explicitly via SetColumn(...), which Bun does not filter.

The `forge:version` column is the exception to that exception: it is excluded from updatableSet entirely, so a mask naming it is an UnknownFieldError rather than a deliberate assertion. A masked write is still version-CHECKED — writing one field of a row someone else has since rewritten is the same lost update as replacing the whole row — so the same predicate and increment apply here. See applyVersionGuard.

Like Update, every column the write did not set is read back, so the unmasked fields of entity hold the stored row afterwards rather than whatever the caller's struct carried. For a narrow write — stamping one column of a wide row — that read-back is most of the statement's cost; pass NoReadBack to make it exec-only.

opts tune this one call. Preserve is a full-replace option and is rejected here: the mask already names exactly what is written.

func (*Repo[M]) Upsert

func (r *Repo[M]) Upsert(ctx context.Context, db orm.Context, entity *M) error

Upsert writes entity by its primary key: INSERT, or on a PK conflict, UPDATE the existing row. It is Repo's ONLY upsert verb — Create stays a plain INSERT (see its doc comment) for exactly the callers this one is not for: an idempotent ingest, a sync-from-external-system, or a seed-or-update flow that does not know in advance whether the row exists.

Conflict target

The conflict target is always the primary key. Upserting on an arbitrary unique index (a natural key distinct from the PK) is a materially bigger design — it needs its own conflict-column parameter, its own EXCLUDED-vs-existing-PK reconciliation, and its own tests — and is out of scope here. A caller that needs it writes the ON CONFLICT query by hand against db.Bun().

SET list

The DO UPDATE SET list is r.m.updatable — the SAME allowlist a full-replace Update writes, which already excludes the PK, deleted_at, and (under managed timestamps) created_at/updated_at, plus any ,skipupdate column. An upsert is a write like any other: it must not let a round-tripped entity clobber a server-owned or secret column any more than Update may, so the two share one list rather than each maintaining its own idea of what is writable.

Timestamps

Both branches follow Create's rule (stampCreate): a created_at or updated_at the caller set is kept, an unset one is now. created_at is written only on the INSERT path; updated_at is written on both, the same value either way, so the row's updated_at does not depend on which branch ran. An upsert that rewrote created_at on a conflict would misreport when the row was actually born — the DO UPDATE SET list never names created_at for exactly that reason.

Soft delete

An upsert onto a tombstoned row RESURRECTS it (clears deleted_at along with the rest of the updatable set) rather than silently no-op'ing or erroring. Bun does not auto-scope INSERT the way it scopes SELECT/ DELETE, and there is no WHERE clause on an INSERT to guard with the way Update's deleted_at IS NULL guard works — the row does not "exist" from the read side, so an upsert that refused to touch it would look indistinguishable from one that inserted successfully. Resurrection is the one behavior consistent with the verb's own name: the caller asserted "this row should exist with these values", and a soft-deleted row is, for that purpose, an absent one that Upsert is allowed to (re)create. A caller that wants tombstones to stay dead must Get first and branch.

Server-allocated / string PKs

A caller supplying an EMPTY PK on a server-allocated (autoincrement) column cannot conflict with anything — there is no matching value to find — so it always inserts a fresh row exactly like Create. The same is true of an empty string PK, except Upsert does NOT ULID-generate one the way Create does: a generated PK can never already be present in the table, so ON CONFLICT could not fire regardless, and doing so would make Upsert silently degrade into Create for its most common accidental misuse (forgetting to set the PK) instead of surfacing the caller's mistake as a NOT NULL violation.

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"
	// Entity projects the request onto the internal entity; see
	// CreateOp.Entity for why it can fail and why it takes ctx, and
	// ErrEntityRequired for the one failure that is the CALLER's fault
	// rather than the data's.
	Entity func(ctx context.Context, req *Req) (Ent, error)
	// Persist writes the whole row. See GetOp.Fetch for the variadic
	// opts: on an update they are what stops a caller reassigning
	// someone else's row to themselves, because the predicate is
	// evaluated against the STORED row rather than the submitted entity.
	Persist func(ctx context.Context, entity Ent, opts ...orm.QueryOption) error
	// Pack projects the updated entity onto the response; see CreateOp.Pack
	// for why it returns an error.
	Pack func(entity Ent) (*Resp, error)

	// 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.
	//
	// The variadic opts mean the same thing they do on Persist.
	PersistMasked func(ctx context.Context, entity Ent, fields []string, opts ...orm.QueryOption) error

	// ReadOnly names the columns a CLIENT may not write through this op:
	// those whose entity wire field is marked `forge:read-only` or
	// `forge:computed`. HandleUpdate refuses an update_mask path naming
	// one with InvalidArgument (reason unknown_field) before anything
	// runs, and the generated Persist keeps the same columns out of a
	// full replace with Preserve — so a client can neither set a
	// server-owned column nor reset it by omitting it.
	//
	// The refusal lives here, on the op, rather than in the repository,
	// because the same repository is how the code that OWNS these
	// columns writes them: a custom RPC's Update<Entity>Masked naming
	// "status" must land, while a client's update_mask naming "status"
	// must not. Only the op knows which of the two is calling.
	//
	// Generated from the proto on every `forge generate`. An override
	// that wants AIP-203's "silently ignore output-only paths" instead
	// can set it to nil and filter in PersistMasked.
	ReadOnly []string
}

UpdateOp wires the per-RPC concerns of an Update handler. The shim supplies an Entity closure that projects the request onto the internal entity, returning ErrEntityRequired when the request omitted it.

type UpdateOption

type UpdateOption func(*updateOptions)

UpdateOption tunes one Update or UpdateMasked call. See Preserve and NoReadBack.

func NoReadBack added in v0.1.46

func NoReadBack() UpdateOption

NoReadBack makes ONE Update or UpdateMasked exec-only: the statement carries no RETURNING clause, so nothing is scanned back into the entity.

By default a write reads back every column it did not set, plus every generated column (see readBackUnwritten), so the caller holds the stored row afterwards. That is the right default for a CRUD response packed from the entity, and the wrong cost for a write whose caller discards the entity — a worker stamping dispatched_at on a 20-column row paid for 19 columns of RETURNING and their scan on every call (10 → 102 allocations, 360 B → 9.8 KB, against the hand-written UPDATE it replaced).

With NoReadBack, after a nil return the entity holds exactly what the caller put in it, plus what the repository computes in Go: the stamped updated_at and, for a forge:version entity, the advanced version. The columns the write did not set, and any GENERATED column, are NOT refreshed — re-read the row if you need them. Everything else is unchanged: the soft-delete guard, the version check, and a zero-row write returning orm.ErrNoRows (or Aborted on a version conflict).

func Preserve

func Preserve(columns ...string) UpdateOption

Preserve keeps the named columns out of ONE full-replace Update's SET clause, whatever the entity holds for them. Like every column the write does not set, they are read back, so the entity reports the stored values afterwards.

It is the per-CALL twin of ,skipupdate. A ,skipupdate column (forge:immutable, a forge:secret field) is held back from every full replace, because what makes rewriting it unsafe is a fact about the COLUMN. Preserve expresses a fact about the CALLER. The generated CRUD Update op passes the entity's forge:read-only and forge:computed columns: a client may not write those, but the custom RPC that owns them writes through this same repository and must. Declaring that on the column would take the write away from the code that owns it.

A name that is not a column of the entity is an error, not a no-op: a misspelled Preserve would otherwise drop its protection with no symptom. A column a full replace never writes anyway (the PK, a forge:version or GENERATED column, a ,skipupdate column) is accepted and changes nothing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL