bbl

package module
v0.0.0-...-41d25ed Latest Latest
Warning

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

Go to latest
Published: Apr 7, 2026 License: Apache-2.0 Imports: 37 Imported by: 0

README

bbl

Institutional repository system for Ghent University Library.

Prerequisites

  • Go 1.25+
  • Node.js
  • Docker & Docker Compose

Quick start

# Start services
docker compose up -d --remove-orphans

# Install dependencies
go mod download && npm install

# Build assets + templ
make build

# Setup database and seed dev data
export BBL_CONFIG=ugent/config.dev.yaml
go run ugent/cmd/bbl dev reset
go run ugent/cmd/bbl dev seed

# Run (auto-reloads on file changes)
make dev

The app runs at http://localhost:3000. Log in via mock OIDC at http://localhost:3000/backoffice (enter any username).

Complete reset

To start completely fresh (wipe volumes, rebuild):

docker compose down -v
docker compose up -d --remove-orphans
go run ugent/cmd/bbl dev reset
go run ugent/cmd/bbl dev seed

Configuration

Config path is set via BBL_CONFIG env var. For development, use ugent/config.dev.yaml — self-contained, mock sources read from local fixture files, no external credentials needed.

Local services

Service Port Purpose
PostgreSQL 3351 Primary database
OpenSearch 3352 Search index
Mock OIDC 3350 Authentication
Mailpit 3360 Mail catcher (web UI)
Mailpit SMTP 3361 SMTP endpoint
LocalStack S3 3371 File storage
citeproc 8085 Citation formatting

Email in development

All outbound email is caught by Mailpit. View it at http://localhost:3360. SMTP is at localhost:3361. Seed users have @bbl.test addresses.

Key commands

make dev                                # Dev server + asset watcher
make build                              # Build everything

bbl dev reset                           # Drop + recreate schema
bbl dev seed                            # Seed data + import sources + reindex
bbl migrate up                          # Run migrations
bbl users import-source <source>        # Import users from source
bbl works import-source <source>        # Import works from source
bbl works import <source>               # Import works from stdin
bbl reindex works                       # Reindex works in OpenSearch

Tests

go test ./...

Documentation

Index

Constants

View Source
const (
	OrganizationStatusPublic  = "public"
	OrganizationStatusDeleted = "deleted"
)
View Source
const (
	PersonStatusPublic  = "public"
	PersonStatusDeleted = "deleted"
)
View Source
const (
	ProjectStatusPublic  = "public"
	ProjectStatusDeleted = "deleted"
)
View Source
const (
	RecordTypeOrganization = "organization"
	RecordTypePerson       = "person"
	RecordTypeProject      = "project"
	RecordTypeWork         = "work"
)

Record type discriminators for entity types.

View Source
const (
	UploadStatusPending  = "pending"
	UploadStatusComplete = "complete"
)
View Source
const (
	WorkStatusPrivate = "private"
	WorkStatusPublic  = "public"
	WorkStatusDeleted = "deleted"
)

Work status values.

View Source
const (
	WorkReviewPending  = "pending"
	WorkReviewInReview = "in_review"
	WorkReviewReturned = "returned"
)

Work review status values. Empty string means not in review.

View Source
const (
	WorkDeleteWithdrawn = "withdrawn"
	WorkDeleteRetracted = "retracted"
	WorkDeleteTakedown  = "takedown"
)

Work delete kind values (set when status = deleted).

View Source
const TaskBatchEdit = "batch_edit"
View Source
const TaskRebuildWorkRepresentation = "rebuild_work_representation"

Variables

View Source
var (
	ErrNotFound    = errors.New("not found")
	ErrConflict    = errors.New("conflict")
	ErrCuratorLock = errors.New("field is locked by a curator")
)

Functions

func BuildOps

func BuildOps(batchOps []BatchOp) func(*Work) []Op

BuildOps converts batch ops into a buildOps callback for BatchUpdateAndIndex. The callback stamps each op with the work's RecordID and returns concrete Ops.

func Decrypt

func Decrypt(key, ciphertext []byte) ([]byte, error)

Decrypt decrypts ciphertext produced by Encrypt using the given 32-byte key.

func EncodeWork

func EncodeWork(format string, work *Work) ([]byte, error)

EncodeWork is a convenience for encoding a single work.

func Encrypt

func Encrypt(key, plaintext []byte) ([]byte, error)

Encrypt encrypts plaintext using AES-256-GCM with the given 32-byte key. The returned ciphertext includes a random nonce prefix.

func HasWorkEncoder

func HasWorkEncoder(format string) bool

HasWorkEncoder reports whether a work encoder is registered for the given format.

func MigrateDown

func MigrateDown(ctx context.Context, dsn string) error

func MigrateDownTo

func MigrateDownTo(ctx context.Context, dsn string, version int) error

func MigrateUp

func MigrateUp(ctx context.Context, dsn string) error

func MigrateUpTo

func MigrateUpTo(ctx context.Context, dsn string, version int) error

func NewBatchEditTask

func NewBatchEditTask(s *Services) *catbird.Task

func NewRebuildWorkRepresentationTask

func NewRebuildWorkRepresentationTask(s *Services) *catbird.Task

func ReadWorks

func ReadWorks(r io.Reader, format string) (iter.Seq2[*ImportWorkInput, error], error)

ReadWorks is a convenience for reading works from a reader.

func RegisterWorkDecoder

func RegisterWorkDecoder(format string, factory func() WorkDecoder)

RegisterWorkDecoder registers a custom work decoder format.

func RegisterWorkEncoder

func RegisterWorkEncoder(format string, factory func() WorkEncoder)

RegisterWorkEncoder registers a custom work encoder format.

func RegisterWorkReader

func RegisterWorkReader(format string, factory func() WorkReader)

RegisterWorkReader registers a custom work reader format.

func RegisterWorkWriter

func RegisterWorkWriter(format string, factory func() WorkWriter)

RegisterWorkWriter registers a custom work writer format.

func SearchAllOrganizations

func SearchAllOrganizations(ctx context.Context, idx OrganizationIndex, opts *SearchOpts) iter.Seq2[OrganizationHit, error]

SearchAllOrganizations returns an iterator over all organization hits matching the query.

func SearchAllPeople

func SearchAllPeople(ctx context.Context, idx PersonIndex, opts *SearchOpts) iter.Seq2[PersonHit, error]

SearchAllPeople returns an iterator over all person hits matching the query.

func SearchAllProjects

func SearchAllProjects(ctx context.Context, idx ProjectIndex, opts *SearchOpts) iter.Seq2[ProjectHit, error]

SearchAllProjects returns an iterator over all project hits matching the query.

func SearchAllWorks

func SearchAllWorks(ctx context.Context, idx WorkIndex, opts *SearchOpts) iter.Seq2[WorkHit, error]

SearchAllWorks returns an iterator over all work hits matching the query, using cursor-based pagination internally.

func Slugify

func Slugify(name, fallback string) string

Slugify returns a URL-safe, lowercase slug derived from name. It decomposes unicode (NFKD), strips non-ASCII, lowercases, and replaces non-alphanumeric runs with a single hyphen. Returns fallback if the result would be empty.

func WorkDecoderFormats

func WorkDecoderFormats() []string

WorkDecoderFormats returns the available decoder format names.

func WorkDecoderFormatsHelp

func WorkDecoderFormatsHelp() string

WorkDecoderFormatsHelp returns a comma-separated list of available decoder formats.

func WorkEncoderFormats

func WorkEncoderFormats() []string

WorkEncoderFormats returns the available encoder format names.

func WorkEncoderFormatsHelp

func WorkEncoderFormatsHelp() string

WorkEncoderFormatsHelp returns a comma-separated list of available encoder formats.

func WorkReaderFormats

func WorkReaderFormats() []string

WorkReaderFormats returns the available reader format names.

func WorkReaderFormatsHelp

func WorkReaderFormatsHelp() string

WorkReaderFormatsHelp returns a comma-separated list of available reader formats.

func WorkWriterFormats

func WorkWriterFormats() []string

WorkWriterFormats returns the available writer format names.

func WorkWriterFormatsHelp

func WorkWriterFormatsHelp() string

WorkWriterFormatsHelp returns a comma-separated list of available writer formats.

func WriteWork

func WriteWork(w io.Writer, exp WorkWriter, work *Work) error

WriteWork is a convenience for writing a single work (Begin+Encode+End).

func WriteWorks

func WriteWorks(w io.Writer, exp WorkWriter, works iter.Seq2[*Work, error]) (int, error)

WriteWorks writes works from an iterator using the given writer.

Types

type AndCondition

type AndCondition struct {
	Or    []*OrCondition `json:"or,omitempty"`
	Terms *TermsFilter   `json:"terms,omitempty"`
}

AndCondition is a single clause in a conjunction. It is either an OR group or a terms filter.

type Append

type Append struct {
	RecordType string `json:"record_type"`
	RecordID   ID     `json:"id"`
	Field      string `json:"field"`
	Val        any    `json:"val"` // single item, e.g. Keyword{Val: "polymer"}
}

Append appends an item to a collection field if not already present. Decomposes to Set — the engine sees a primitive "set" in edits and log.

type AuthProvider

type AuthProvider struct {
	Provider string `json:"provider"`
}

AuthProvider is an entry in User.AuthProviders. Stored as a jsonb array to allow additional fields (e.g. added_at) in future.

type Authorizer

type Authorizer struct {
	// contains filtered or unexported fields
}

Authorizer checks capabilities against role config and ad-hoc grants.

func NewAuthorizer

func NewAuthorizer(roles map[string]*RoleConfig) *Authorizer

NewAuthorizer creates an Authorizer from parsed role configs.

func (*Authorizer) Priority

func (a *Authorizer) Priority(role string) int

Priority returns the assertion priority for a role name.

func (*Authorizer) Resolve

func (a *Authorizer) Resolve(user *User, adHocGrants []Grant)

Resolve populates user.UserGrants with role config grants, ad-hoc grants, and priority.

func (*Authorizer) Role

func (a *Authorizer) Role(name string) *RoleConfig

Role returns the config for a role name, or nil if unknown.

func (*Authorizer) ValidRole

func (a *Authorizer) ValidRole(role string) bool

ValidRole returns true if the role name exists in the config.

type BatchEditInput

type BatchEditInput struct {
	ListID   ID        `json:"list_id"`
	UserID   ID        `json:"user_id"`
	Statuses []string  `json:"statuses"`
	Ops      []BatchOp `json:"ops"`
}

type BatchEditOutput

type BatchEditOutput struct {
	Updated   int    `json:"updated"`
	Skipped   int    `json:"skipped"`
	Conflicts int    `json:"conflicts"`
	Errors    int    `json:"errors"`
	Error     string `json:"error,omitempty"`
}

type BatchError

type BatchError struct {
	WorkID ID
	Err    error
}

BatchError records a per-work error during batch update.

type BatchOp

type BatchOp struct {
	Op    string // "set", "hide", "unset", "append", "remove"
	Field string // "volume", "keywords", etc.
	Value string // raw value — empty for hide/unset
}

BatchOp is a serialized op template — no RecordID yet. Parsed from CSV, stamped with a work ID during batch iteration.

func ParseBatchOps

func ParseBatchOps(r io.Reader) ([]BatchOp, error)

ParseBatchOps reads batch ops from a CSV reader. Expected format: op,field,value header followed by data rows.

type BatchResult

type BatchResult struct {
	Updated   int
	Skipped   int
	Conflicts []ID
	Errors    []BatchError
}

BatchResult holds the outcome of a batch update.

type ClaimWorkUploads

type ClaimWorkUploads struct {
	WorkID    ID   `json:"id"`
	UploadIDs []ID `json:"upload_ids"`
	// contains filtered or unexported fields
}

ClaimWorkUploads is an Op that moves completed uploads into a work's files. Participates in the Update transaction so file claiming is atomic with field updates.

type Conference

type Conference struct {
	Name      string    `json:"name,omitempty"`
	Organizer string    `json:"organizer,omitempty"`
	Location  string    `json:"location,omitempty"`
	StartDate time.Time `json:"start_date,omitzero"`
	EndDate   time.Time `json:"end_date,omitzero"`
}

Conference is a conference associated with a work.

type CreateOrganization

type CreateOrganization struct {
	ID        ID         `json:"id"`
	Kind      string     `json:"kind"`
	StartDate *time.Time `json:"start_date"`
	EndDate   *time.Time `json:"end_date"`
}

CreateOrganization creates a new organization entity.

type CreatePerson

type CreatePerson struct {
	ID ID `json:"id"`
}

CreatePerson creates a new person entity.

type CreateProject

type CreateProject struct {
	ID        ID         `json:"id"`
	Status    string     `json:"status"`
	StartDate *time.Time `json:"start_date"`
	EndDate   *time.Time `json:"end_date"`
}

CreateProject creates a new project entity.

type CreateWork

type CreateWork struct {
	ID     ID     `json:"id"`
	Kind   string `json:"kind"`
	Status string `json:"status"` // defaults to WorkStatusPrivate
}

CreateWork creates a new work entity.

type DeleteOrganization

type DeleteOrganization struct {
	OrganizationID ID `json:"id"`
}

DeleteOrganization soft-deletes an organization.

type DeletePerson

type DeletePerson struct {
	PersonID ID `json:"id"`
}

DeletePerson soft-deletes a person.

type DeleteProject

type DeleteProject struct {
	ProjectID ID `json:"id"`
}

DeleteProject soft-deletes a project.

type DeleteWork

type DeleteWork struct {
	WorkID     ID     `json:"id"`
	DeleteKind string `json:"delete_kind"`
}

DeleteWork soft-deletes a work.

type DownloadURLOpts

type DownloadURLOpts struct {
	TTL         time.Duration
	Filename    string // sets Content-Disposition: attachment; filename="..."
	ContentType string // overrides Content-Type header
}

type Extent

type Extent struct {
	Start string `json:"start,omitempty"`
	End   string `json:"end,omitempty"`
}

Extent is a range (e.g. pages).

type Facet

type Facet struct {
	Name string       `json:"name"`
	Vals []FacetValue `json:"vals"`
}

Facet represents a faceted search result with counts per value.

type FacetValue

type FacetValue struct {
	Val   string `json:"val"`
	Count int    `json:"count"`
}

FacetValue is a single value in a facet with its document count.

type FieldEdit

type FieldEdit struct {
	Val    json.RawMessage `json:"val,omitempty"`
	Hidden bool            `json:"hidden,omitempty"`
	Role   string          `json:"role"`
	Rev    int64           `json:"rev"`
}

FieldEdit is a human assertion for a single field. Stored in the record's field_edits jsonb column, keyed by field name.

type FieldStrategy

type FieldStrategy int

FieldStrategy determines how a field is resolved from multiple sources.

const (
	// FieldExclusive: one asserter wins (highest priority source, or human edit).
	FieldExclusive FieldStrategy = iota
)

type FieldType

type FieldType struct {
	Name string
	// contains filtered or unexported fields
}

FieldType describes a field value type: its name and the runtime operations (unmarshal, equality, validation). Each type is independent — no scalar/collection hierarchy. A type that holds a slice implements unmarshal/equal/validate directly for the whole slice.

type FieldVal

type FieldVal struct {
	// contains filtered or unexported fields
}

FieldVal is a lazy field value that carries its type spec alongside both the raw JSON and typed Go representations. Conversion between the two is on-demand and cached — at most one marshal and one unmarshal per value, regardless of how many times it's accessed.

func (*FieldVal) Equal

func (fv *FieldVal) Equal(other *FieldVal) (bool, error)

Equal compares two FieldVals for typed equality.

func (*FieldVal) IsZero

func (fv *FieldVal) IsZero() bool

IsZero reports whether the FieldVal holds no meaningful value.

func (*FieldVal) Raw

func (fv *FieldVal) Raw() (json.RawMessage, error)

Raw returns the JSON representation, marshaling lazily if needed.

func (*FieldVal) Val

func (fv *FieldVal) Val() (any, error)

Val returns the typed Go value, unmarshaling lazily if needed.

func (*FieldVal) Validate

func (fv *FieldVal) Validate() error

Validate runs shape validation on the typed value.

type FileStore

type FileStore interface {
	Upload(ctx context.Context, r io.Reader) (id string, err error)
	Download(ctx context.Context, id string, w io.Writer) error
	Delete(ctx context.Context, id string) error
	Exists(ctx context.Context, id string) (bool, error)

	NewUploadURL(ctx context.Context, ttl time.Duration) (id string, url string, err error)
	NewDownloadURL(ctx context.Context, id string, opts DownloadURLOpts) (string, error)
}

FileStore is an object storage backend for file uploads/downloads. The store generates blob IDs — callers treat them as opaque strings.

type Grant

type Grant struct {
	Capability string `json:"capability"`
	Scope      string `json:"scope"`
	Match      string `json:"match,omitempty"`
	MatchID    *ID    `json:"match_id,omitempty"`
}

Grant is a capability on a scope. Used in both role config and bbl_grants.

Role config (static): Grant{Capability: "edit_private", Scope: "work", Match: "own"} DB grant (ad-hoc): Grant{Capability: "edit_private", Scope: "work", MatchID: &id} Type-wide: Grant{Capability: "edit_private", Scope: "work", Match: "all"} Global: Grant{Capability: "manage_users"}

func ParseGrant

func ParseGrant(s string) (Grant, error)

ParseGrant parses a DSL string like "edit_private:work:own".

func (Grant) String

func (g Grant) String() string

type Hide

type Hide struct {
	RecordType string `json:"record_type"`
	RecordID   ID     `json:"id"`
	Field      string `json:"field"`
}

Hide asserts that a field intentionally has no value.

type ID

type ID [16]byte

ID is a sortable, UUID-compatible unique identifier. Generated as a ID for sequential B-tree inserts; stored as the PostgreSQL uuid type. Implements pgtype.UUIDScanner and pgtype.UUIDValuer so pgx works directly with the raw bytes, bypassing string-format conversion.

func ParseID

func ParseID(s string) (ID, error)

ParseID parses a string into an ID. Returns an error if the string is invalid.

func (ID) MarshalText

func (u ID) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler.

func (*ID) ScanUUID

func (u *ID) ScanUUID(v pgtype.UUID) error

ScanUUID implements pgtype.UUIDScanner for pgx v5 uuid column scanning.

func (ID) String

func (u ID) String() string

String returns the ID string representation (base32 Crockford).

func (ID) UUIDValue

func (u ID) UUIDValue() (pgtype.UUID, error)

UUIDValue implements pgtype.UUIDValuer for pgx v5 uuid column encoding.

func (*ID) UnmarshalText

func (u *ID) UnmarshalText(b []byte) error

UnmarshalText implements encoding.TextUnmarshaler. Accepts both ULID (Crockford base32) and UUID (hyphenated hex) formats, since PostgreSQL's uuid type serializes as hyphenated hex in JSON.

type Identifier

type Identifier struct {
	Scheme string `json:"scheme"`
	Val    string `json:"val"`
}

Identifier is a scheme/val pair used for entity identifiers across all entity types.

type ImportOrganizationInput

type ImportOrganizationInput struct {
	ID              *ID        `json:"id,omitempty"`
	SourceID        string     `json:"source_id"`
	Kind            string     `json:"kind"`
	StartDate       *time.Time `json:"start_date,omitempty"`
	EndDate         *time.Time `json:"end_date,omitempty"`
	SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
	SourceRecord    []byte     `json:"-"`
	// contains filtered or unexported fields
}

ImportOrganizationInput carries all data for one organization record arriving from a source.

func (*ImportOrganizationInput) MarshalJSON

func (in *ImportOrganizationInput) MarshalJSON() ([]byte, error)

func (*ImportOrganizationInput) SetField

func (in *ImportOrganizationInput) SetField(name string, val any)

func (*ImportOrganizationInput) UnmarshalJSON

func (in *ImportOrganizationInput) UnmarshalJSON(data []byte) error

type ImportOrganizationRel

type ImportOrganizationRel struct {
	Ref       Ref        `json:"ref"`
	Kind      string     `json:"kind"`
	StartDate *time.Time `json:"start_date,omitempty"`
	EndDate   *time.Time `json:"end_date,omitempty"`
}

ImportOrganizationRel describes a relationship to another organization.

type ImportPersonAffiliation

type ImportPersonAffiliation struct {
	Ref Ref `json:"ref"`
}

ImportPersonAffiliation links a person to an organization during import.

type ImportPersonInput

type ImportPersonInput struct {
	ID              *ID        `json:"id,omitempty"`
	SourceID        string     `json:"source_id"`
	SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
	SourceRecord    []byte     `json:"-"`
	// contains filtered or unexported fields
}

ImportPersonInput carries all data for one person record arriving from a source.

func (*ImportPersonInput) MarshalJSON

func (in *ImportPersonInput) MarshalJSON() ([]byte, error)

func (*ImportPersonInput) SetField

func (in *ImportPersonInput) SetField(name string, val any)

func (*ImportPersonInput) UnmarshalJSON

func (in *ImportPersonInput) UnmarshalJSON(data []byte) error

type ImportProjectInput

type ImportProjectInput struct {
	ID              *ID        `json:"id,omitempty"`
	SourceID        string     `json:"source_id"`
	Status          string     `json:"status,omitempty"`
	StartDate       *time.Time `json:"start_date,omitempty"`
	EndDate         *time.Time `json:"end_date,omitempty"`
	SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
	SourceRecord    []byte     `json:"-"`
	// contains filtered or unexported fields
}

ImportProjectInput carries all data for one project record arriving from a source.

func (*ImportProjectInput) MarshalJSON

func (in *ImportProjectInput) MarshalJSON() ([]byte, error)

func (*ImportProjectInput) SetField

func (in *ImportProjectInput) SetField(name string, val any)

func (*ImportProjectInput) UnmarshalJSON

func (in *ImportProjectInput) UnmarshalJSON(data []byte) error

type ImportProjectParticipant

type ImportProjectParticipant struct {
	Ref  Ref    `json:"ref"`
	Role string `json:"role,omitempty"`
}

ImportProjectParticipant links a project to a person during import.

type ImportUserInput

type ImportUserInput struct {
	SourceName   string           `json:"source_name,omitempty"`
	SourceID     string           `json:"source_id"`
	ExpiresAt    *time.Time       `json:"expires_at,omitempty"` // nil = permanent; set for recurring directory sources
	Username     string           `json:"username"`
	Email        string           `json:"email"`
	Name         string           `json:"name"`
	Role         string           `json:"role,omitempty"`
	Identifiers  []UserIdentifier `json:"identifiers,omitempty"`
	AuthProvider string           `json:"auth_provider,omitempty"` // optional — name of the auth provider this source drives (e.g. "ugent_oidc")
}

ImportUserInput carries all data for one user record arriving from a source. Role is only applied on creation; subsequent imports do not overwrite a role set by an admin.

type ImportWorkContributor

type ImportWorkContributor struct {
	PersonRef  *Ref     `json:"person_ref,omitempty"`
	Kind       string   `json:"kind,omitempty"` // "person" (default) or "organization"
	Roles      []string `json:"roles,omitempty"`
	Name       string   `json:"name,omitempty"`
	GivenName  string   `json:"given_name,omitempty"`
	MiddleName string   `json:"middle_name,omitempty"`
	FamilyName string   `json:"family_name,omitempty"`
}

ImportWorkContributor is a contributor arriving from a source.

type ImportWorkInput

type ImportWorkInput struct {
	ID              *ID        `json:"id,omitempty"`
	SourceID        string     `json:"source_id"`
	Kind            string     `json:"kind"`
	Status          string     `json:"status,omitempty"`
	SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
	SourceRecord    []byte     `json:"-"`
	// contains filtered or unexported fields
}

ImportWorkInput carries all data for one work record arriving from a source. IMPORTANT: when adding struct fields, also add the JSON key to importWorkStructuralKeys — otherwise UnmarshalJSON will route it through the domain field registry.

func DecodeWork

func DecodeWork(format string, data []byte) (*ImportWorkInput, error)

DecodeWork is a convenience for decoding a single work.

func (*ImportWorkInput) MarshalJSON

func (in *ImportWorkInput) MarshalJSON() ([]byte, error)

func (*ImportWorkInput) SetField

func (in *ImportWorkInput) SetField(name string, val any)

func (*ImportWorkInput) UnmarshalJSON

func (in *ImportWorkInput) UnmarshalJSON(data []byte) error

type ImportWorkOrganization

type ImportWorkOrganization struct {
	Ref Ref `json:"ref"`
}

ImportWorkOrganization links a work to an organization during import.

type ImportWorkProject

type ImportWorkProject struct {
	Ref Ref `json:"ref"`
}

ImportWorkProject links a work to a project during import.

type ImportWorkRel

type ImportWorkRel struct {
	Ref  Ref    `json:"ref"`
	Kind string `json:"kind"`
}

ImportWorkRel links a work to a related work during import.

type Index

type Index interface {
	Works() WorkIndex
	People() PersonIndex
	Projects() ProjectIndex
	Organizations() OrganizationIndex
}

Index provides access to per-entity search indexes.

type Keyword

type Keyword struct {
	Val string `json:"val"`
}

Keyword is a keyword/subject term on a work.

type List

type List struct {
	ID          ID         `json:"id"`
	Version     int        `json:"version"`
	Slug        string     `json:"slug"`
	Name        string     `json:"name"`
	Description string     `json:"description,omitempty"`
	RecordType  string     `json:"record_type,omitempty"`
	Query       *ListQuery `json:"query,omitempty"`
	AccessLevel string     `json:"access_level"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
	CreatedByID ID         `json:"created_by_id"`
}

func (*List) IsQuery

func (l *List) IsQuery() bool

type ListItem

type ListItem struct {
	RecordType string
	RecordID   ID
	Position   string
}

type ListPublicWorksOpts

type ListPublicWorksOpts struct {
	From   time.Time
	Until  time.Time
	Cursor string // opaque, from previous result
	Limit  int
}

WorkCursor is a keyset pagination cursor for ListPublicWorks. ListPublicWorksOpts holds parameters for ListPublicWorks.

type ListPublicWorksResult

type ListPublicWorksResult struct {
	Works  []*Work
	Cursor string // empty = last page
}

ListPublicWorksResult holds the result of ListPublicWorks.

type ListQuery

type ListQuery struct {
	Query  string       `json:"query,omitempty"`
	Filter *QueryFilter `json:"filter,omitempty"`
}

type ListRepresentationOpts

type ListRepresentationOpts struct {
	Scheme string
	From   *time.Time
	Until  *time.Time
	Cursor string
	Limit  int
}

type ListRepresentationResult

type ListRepresentationResult struct {
	Representations []*Representation
	Cursor          string
}

type LogEntry

type LogEntry struct {
	Op   string          `json:"op"`             // "create", "delete", "set", "hide", "unset"
	Data json.RawMessage `json:"data,omitempty"` // op-specific: {"field":"volume","prev_val":...}
}

LogEntry is an entry written to bbl_log. Produced by all ops — field edits, state changes, lifecycle. Record identity comes from the ResolvedRecord, not the entry.

type Note

type Note struct {
	Kind string `json:"kind,omitempty"`
	Val  string `json:"val"`
}

Note is a typed annotation on a work.

type Op

type Op interface {
	// contains filtered or unexported methods
}

Op is the unit of change submitted to Update. plan() runs during the I/O phase — it may query the DB to discover affected records, then returns pure single-record mutations.

func DecodeUpdate

func DecodeUpdate(data []byte) (Op, error)

DecodeUpdate decodes a JSON-encoded update into a concrete update type.

Wire format:

{"set": "work:volume", "id": "01J...", "val": "42"}
{"hide": "work:volume", "id": "01J..."}
{"unset": "work:volume", "id": "01J..."}
{"append": "work:keywords", "id": "01J...", "val": {"val": "polymer"}}
{"remove": "work:keywords", "id": "01J...", "val": {"val": "draft"}}
{"create": "work", "id": "01J...", "kind": "journal_article"}
{"delete": "work", "id": "01J..."}

type OrCondition

type OrCondition struct {
	And   []*AndCondition `json:"and,omitempty"`
	Terms *TermsFilter    `json:"terms,omitempty"`
}

OrCondition is a single clause in a disjunction. It is either an AND group or a terms filter.

type Organization

type Organization struct {
	ID          ID         `json:"id"`
	RevID       int64      `json:"rev_id"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
	CreatedByID *ID        `json:"created_by_id,omitempty"`
	UpdatedByID *ID        `json:"updated_by_id,omitempty"`
	Kind        string     `json:"kind"`
	Status      string     `json:"status"`
	StartDate   *time.Time `json:"start_date,omitempty"`
	EndDate     *time.Time `json:"end_date,omitempty"`
	DeletedAt   *time.Time `json:"deleted_at,omitempty"`
	DeletedByID *ID        `json:"deleted_by_id,omitempty"`
	// contains filtered or unexported fields
}

func (*Organization) Identifiers

func (o *Organization) Identifiers() []Identifier

func (*Organization) MarshalJSON

func (o *Organization) MarshalJSON() ([]byte, error)

func (*Organization) Names

func (o *Organization) Names() []Text

func (*Organization) Rels

func (o *Organization) Rels() []OrganizationRel

type OrganizationHit

type OrganizationHit struct {
	ID   ID     `json:"id"`
	Kind string `json:"kind"`
	Name string `json:"name"`
}

OrganizationHit is a search result for an organization, containing display fields.

type OrganizationHits

type OrganizationHits struct {
	Hits   []OrganizationHit `json:"hits"`
	Total  int               `json:"total"`
	Cursor string            `json:"cursor,omitempty"`
	Facets []Facet           `json:"facets,omitempty"`
}

OrganizationHits is the result of an organization search query.

type OrganizationIndex

type OrganizationIndex interface {
	Add(ctx context.Context, org *Organization) error
	Delete(ctx context.Context, id ID) error
	DeleteAll(ctx context.Context) error
	Search(ctx context.Context, opts *SearchOpts) (*OrganizationHits, error)
	Reindex(ctx context.Context, all iter.Seq2[*Organization, error], changed func(since time.Time) iter.Seq2[*Organization, error]) error
}

OrganizationIndex is the search index for organizations.

type OrganizationRel

type OrganizationRel struct {
	RelOrganizationID ID         `json:"rel_organization_id"`
	Kind              string     `json:"kind"`
	StartDate         *time.Time `json:"start_date,omitempty"`
	EndDate           *time.Time `json:"end_date,omitempty"`
}

OrganizationRel links two organizations with a typed, optionally temporal relationship.

type OrganizationSource

type OrganizationSource interface {
	Iter(ctx context.Context) (iter.Seq2[*ImportOrganizationInput, error], error)
}

OrganizationSource is the interface implemented by organization import sources.

type Person

type Person struct {
	ID          ID         `json:"id"`
	RevID       int64      `json:"rev_id"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
	CreatedByID *ID        `json:"created_by_id,omitempty"`
	UpdatedByID *ID        `json:"updated_by_id,omitempty"`
	Status      string     `json:"status"`
	DeletedAt   *time.Time `json:"deleted_at,omitempty"`
	DeletedByID *ID        `json:"deleted_by_id,omitempty"`
	// contains filtered or unexported fields
}

func (*Person) Affiliations

func (p *Person) Affiliations() []PersonAffiliation

func (*Person) FamilyName

func (p *Person) FamilyName() string

func (*Person) GivenName

func (p *Person) GivenName() string

func (*Person) Identifiers

func (p *Person) Identifiers() []Identifier

func (*Person) MarshalJSON

func (p *Person) MarshalJSON() ([]byte, error)

func (*Person) MiddleName

func (p *Person) MiddleName() string

func (*Person) Name

func (p *Person) Name() string

func (*Person) StringField

func (p *Person) StringField(name string) string

type PersonAffiliation

type PersonAffiliation struct {
	OrganizationID ID `json:"organization_id"`
}

PersonAffiliation is an affiliation in the resolved fields.

type PersonHit

type PersonHit struct {
	ID   ID     `json:"id"`
	Name string `json:"name"`
}

PersonHit is a search result for a person, containing display fields.

type PersonHits

type PersonHits struct {
	Hits   []PersonHit `json:"hits"`
	Total  int         `json:"total"`
	Cursor string      `json:"cursor,omitempty"`
	Facets []Facet     `json:"facets,omitempty"`
}

PersonHits is the result of a person search query.

type PersonIndex

type PersonIndex interface {
	Add(ctx context.Context, person *Person) error
	Delete(ctx context.Context, id ID) error
	DeleteAll(ctx context.Context) error
	Search(ctx context.Context, opts *SearchOpts) (*PersonHits, error)
	Reindex(ctx context.Context, all iter.Seq2[*Person, error], changed func(since time.Time) iter.Seq2[*Person, error]) error
}

PersonIndex is the search index for people.

type PersonSource

type PersonSource interface {
	Iter(ctx context.Context) (iter.Seq2[*ImportPersonInput, error], error)
}

PersonSource is the interface implemented by person import sources.

type ProfileField

type ProfileField struct {
	Name     string
	Type     *FieldType
	Required string   // "", "always", "public"
	Schemes  []string // for identifier, classification
}

ProfileField is a field definition with profile-level constraints.

func (ProfileField) IsRequired

func (f ProfileField) IsRequired() bool

IsRequired reports whether the field has any required constraint.

type Profiles

type Profiles struct {
	Work         map[string][]ProfileField
	Organization map[string][]ProfileField
	Person       []ProfileField
	Project      []ProfileField
	// contains filtered or unexported fields
}

Profiles holds resolved profiles for all entity types, loaded once at startup.

func LoadProfiles

func LoadProfiles(path string) (*Profiles, error)

LoadProfiles reads the YAML profile config and validates field names against the field registry. Returns an error if any field name is unknown or any section is missing.

func (*Profiles) FieldDefs

func (p *Profiles) FieldDefs(recordType, kind string) []ProfileField

FieldDefs returns the field definitions for a record type and kind. Returns nil if the record type or kind is unknown.

func (*Profiles) OrganizationKinds

func (p *Profiles) OrganizationKinds() []string

OrganizationKinds returns organization kind names in definition order.

func (*Profiles) WorkKinds

func (p *Profiles) WorkKinds() []string

WorkKinds returns work kind names in definition order.

type Project

type Project struct {
	ID          ID         `json:"id"`
	RevID       int64      `json:"rev_id"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
	CreatedByID *ID        `json:"created_by_id,omitempty"`
	UpdatedByID *ID        `json:"updated_by_id,omitempty"`
	Status      string     `json:"status"`
	StartDate   *time.Time `json:"start_date,omitempty"`
	EndDate     *time.Time `json:"end_date,omitempty"`
	DeletedAt   *time.Time `json:"deleted_at,omitempty"`
	DeletedByID *ID        `json:"deleted_by_id,omitempty"`
	// contains filtered or unexported fields
}

func (*Project) Descriptions

func (p *Project) Descriptions() []Text

func (*Project) Identifiers

func (p *Project) Identifiers() []Identifier

func (*Project) MarshalJSON

func (p *Project) MarshalJSON() ([]byte, error)

func (*Project) Participants

func (p *Project) Participants() []ProjectParticipant

func (*Project) Titles

func (p *Project) Titles() []Title

type ProjectHit

type ProjectHit struct {
	ID     ID     `json:"id"`
	Status string `json:"status"`
	Title  string `json:"title"`
}

ProjectHit is a search result for a project, containing display fields.

type ProjectHits

type ProjectHits struct {
	Hits   []ProjectHit `json:"hits"`
	Total  int          `json:"total"`
	Cursor string       `json:"cursor,omitempty"`
	Facets []Facet      `json:"facets,omitempty"`
}

ProjectHits is the result of a project search query.

func SearchPublicProjects

func SearchPublicProjects(ctx context.Context, idx ProjectIndex, opts *SearchOpts) (*ProjectHits, error)

SearchPublicProjects searches for projects with status=public.

type ProjectIndex

type ProjectIndex interface {
	Add(ctx context.Context, project *Project) error
	Delete(ctx context.Context, id ID) error
	DeleteAll(ctx context.Context) error
	Search(ctx context.Context, opts *SearchOpts) (*ProjectHits, error)
	Reindex(ctx context.Context, all iter.Seq2[*Project, error], changed func(since time.Time) iter.Seq2[*Project, error]) error
}

ProjectIndex is the search index for projects.

type ProjectParticipant

type ProjectParticipant struct {
	PersonID ID     `json:"person_id"`
	Role     string `json:"role,omitempty"`
}

ProjectParticipant is a participant in the resolved fields.

type ProjectSource

type ProjectSource interface {
	Iter(ctx context.Context) (iter.Seq2[*ImportProjectInput, error], error)
}

ProjectSource is the interface implemented by project import sources.

type QueryFilter

type QueryFilter struct {
	And []*AndCondition `json:"and"`
}

QueryFilter represents a conjunction of conditions for search filtering.

Filter expressions select documents by field values using a simple query language:

field=value             exact match
field=val1|val2         match any of the values
field=a field=b         AND (both must match)
field=a or field=b      OR (either must match)
(field=a or field=b)    grouping with parentheses

Examples:

status=public
status=public kind=book|article
kind=book or kind=conference_paper
status=public (kind=book or kind=article)
(status=public and kind=book) or (status=private and kind=article)

Use ParseQueryFilter to parse a filter expression string into a QueryFilter.

func ParseQueryFilter

func ParseQueryFilter(str string) (*QueryFilter, error)

ParseQueryFilter parses a search filter expression like "status=public kind=book|article".

func (*QueryFilter) HasTerm

func (qf *QueryFilter) HasTerm(field, term string) bool

HasTerm returns true if the filter contains a terms filter for the given field and term.

type RecordEvent

type RecordEvent struct {
	RecordID     ID         `json:"record_id"`
	RevID        int64      `json:"rev_id"`
	Status       string     `json:"status"`
	MadePublicAt *time.Time `json:"made_public_at,omitempty"`
}

RecordEvent is the payload published to record.{type}.changed topics.

type RecordOp

type RecordOp struct {
	RecordID   ID
	RecordType string
	IsCreate   bool // if true, gather skips loading (record doesn't exist yet)
	Apply      func(rec *RecordState, user *User) ([]LogEntry, error)
	// Write is optional lifecycle SQL (create/delete). If non-nil, called during
	// the write phase. Field ops leave this nil.
	Write lifecycleWriteFn
}

RecordOp is a pure single-record mutation produced by Op.plan().

type RecordState

type RecordState struct {
	RecordType   string
	RecordID     ID
	RevID        int64
	Kind         string
	Status       string
	MadePublicAt *time.Time

	// All source assertions (scalar + relational fields JSONB per source).
	Sources []sourceAssertion

	// Human field edits (scalar + relational field values).
	FieldEdits map[string]FieldEdit

	// Current resolved fields JSONB (for noop detection in prune).
	Fields map[string]json.RawMessage

	// Source priorities (for resolving winning asserter in noop detection).
	Priorities map[string]int

	// Work files (not assertions — loaded and written directly).
	Files []WorkFile

	// Lifecycle flags (set by apply).
	IsNew     bool
	IsDeleted bool
	// contains filtered or unexported fields
}

RecordState is one record's complete write-path state. Groups data by asserter for resolution.

type Ref

type Ref struct {
	ID         *ID         `json:"id,omitempty"`
	SourceID   string      `json:"source_id,omitempty"`
	Identifier *Identifier `json:"identifier,omitempty"`
}

Ref identifies an entity for cross-entity linking during import. Exactly one field should be set.

type Remove

type Remove struct {
	RecordType string `json:"record_type"`
	RecordID   ID     `json:"id"`
	Field      string `json:"field"`
	Val        any    `json:"val"` // item to match, e.g. Keyword{Val: "draft"}
}

Remove removes matching item(s) from a collection field. Decomposes to Set or Hide — the engine sees a primitive "set" or "hide".

type RemoveWorkFiles

type RemoveWorkFiles struct {
	WorkID  ID   `json:"id"`
	FileIDs []ID `json:"file_ids"`
	// contains filtered or unexported fields
}

RemoveWorkFiles is an Op that removes files from a work on form submit.

func (*RemoveWorkFiles) RemovedBlobIDs

func (m *RemoveWorkFiles) RemovedBlobIDs() []string

type Repo

type Repo struct {
	Profiles *Profiles // nil = no profile validation
	// contains filtered or unexported fields
}

Repo is the single repository backed by PostgreSQL. No interface is defined — PostgreSQL features are used pervasively and there are no plans to support another database.

func NewRepo

func NewRepo(ctx context.Context, connString string, tokenKey []byte) (*Repo, error)

func (*Repo) AddListItems

func (r *Repo) AddListItems(ctx context.Context, listID ID, items []ListItem) error

func (*Repo) AddWorkCollectionItems

func (r *Repo) AddWorkCollectionItems(ctx context.Context, collectionID ID, workIDs []ID) error

func (*Repo) AddWorkFile

func (r *Repo) AddWorkFile(ctx context.Context, wf *WorkFile) error

func (*Repo) CleanupUploads

func (r *Repo) CleanupUploads(ctx context.Context, olderThan time.Duration) ([]string, error)

CleanupUploads deletes stale uploads older than the given duration. Returns the blob_ids of deleted rows so the caller can clean the store.

func (*Repo) Close

func (r *Repo) Close()

func (*Repo) ConfirmUpload

func (r *Repo) ConfirmUpload(ctx context.Context, id ID, size int, sha256 string) error

func (*Repo) CreateList

func (r *Repo) CreateList(ctx context.Context, list *List) error

func (*Repo) CreateUpload

func (r *Repo) CreateUpload(ctx context.Context, u *Upload) error

func (*Repo) CreateUser

func (r *Repo) CreateUser(ctx context.Context, attrs UserAttrs) (*User, error)

CreateUser inserts a new user. Intended for manual admin creation. Returns ErrConflict if the username is already taken.

func (*Repo) CreateWorkCollection

func (r *Repo) CreateWorkCollection(ctx context.Context, c *WorkCollection) error

func (*Repo) DeleteList

func (r *Repo) DeleteList(ctx context.Context, id ID) error

func (*Repo) DeleteUpload

func (r *Repo) DeleteUpload(ctx context.Context, id ID) (string, error)

func (*Repo) DeleteWorkCollection

func (r *Repo) DeleteWorkCollection(ctx context.Context, id ID) error

func (*Repo) DeleteWorkFile

func (r *Repo) DeleteWorkFile(ctx context.Context, id ID) error

func (*Repo) DeleteWorkRepresentation

func (r *Repo) DeleteWorkRepresentation(ctx context.Context, workID ID, scheme string) error

func (*Repo) EachListWork

func (r *Repo) EachListWork(ctx context.Context, listID ID, statuses []string) iter.Seq2[*Work, error]

EachListWork iterates over all works in an items-based list, filtered by statuses.

func (*Repo) EachOrganization

func (r *Repo) EachOrganization(ctx context.Context) iter.Seq2[*Organization, error]

EachOrganization returns an iterator over all organizations, ordered by id.

func (*Repo) EachOrganizationSince

func (r *Repo) EachOrganizationSince(ctx context.Context, since time.Time) iter.Seq2[*Organization, error]

EachOrganizationSince returns an iterator over organizations updated since the given time, ordered by id.

func (*Repo) EachPerson

func (r *Repo) EachPerson(ctx context.Context) iter.Seq2[*Person, error]

EachPerson returns an iterator over all people, ordered by id.

func (*Repo) EachPersonSince

func (r *Repo) EachPersonSince(ctx context.Context, since time.Time) iter.Seq2[*Person, error]

EachPersonSince returns an iterator over people updated since the given time, ordered by id.

func (*Repo) EachProject

func (r *Repo) EachProject(ctx context.Context) iter.Seq2[*Project, error]

EachProject returns an iterator over all projects, ordered by id.

func (*Repo) EachProjectSince

func (r *Repo) EachProjectSince(ctx context.Context, since time.Time) iter.Seq2[*Project, error]

EachProjectSince returns an iterator over projects updated since the given time, ordered by id.

func (*Repo) EachWork

func (r *Repo) EachWork(ctx context.Context) iter.Seq2[*Work, error]

EachWork returns an iterator over all works, ordered by id.

func (*Repo) EachWorkCollectionWork

func (r *Repo) EachWorkCollectionWork(ctx context.Context, collectionID ID) iter.Seq2[*Work, error]

func (*Repo) EachWorkSince

func (r *Repo) EachWorkSince(ctx context.Context, since time.Time) iter.Seq2[*Work, error]

EachWorkSince returns an iterator over works updated since the given time, ordered by id.

func (*Repo) GetEarliestWorkTimestamp

func (r *Repo) GetEarliestWorkTimestamp(ctx context.Context) (time.Time, error)

GetEarliestWorkTimestamp returns the earliest updated_at of any public work.

func (*Repo) GetGrants

func (r *Repo) GetGrants(ctx context.Context, userID ID) ([]Grant, error)

GetGrants loads all active (non-revoked, non-expired) grants for a user. Used once per request to preload grants for the access check.

func (*Repo) GetList

func (r *Repo) GetList(ctx context.Context, id ID) (*List, error)

func (*Repo) GetListBySlug

func (r *Repo) GetListBySlug(ctx context.Context, slug string) (*List, error)

func (*Repo) GetOrganization

func (r *Repo) GetOrganization(ctx context.Context, id ID) (*Organization, error)

func (*Repo) GetPerson

func (r *Repo) GetPerson(ctx context.Context, id ID) (*Person, error)

func (*Repo) GetProject

func (r *Repo) GetProject(ctx context.Context, id ID) (*Project, error)

func (*Repo) GetUpload

func (r *Repo) GetUpload(ctx context.Context, id ID) (*Upload, error)

func (*Repo) GetUser

func (r *Repo) GetUser(ctx context.Context, id ID) (*User, error)

GetUser fetches a user by primary key. Returns ErrNotFound if no row exists.

func (*Repo) GetUserByIdentifier

func (r *Repo) GetUserByIdentifier(ctx context.Context, scheme, val string) (*User, error)

GetUserByIdentifier looks up a user by auth claim (scheme, val). This is the primary login lookup path. Returns ErrNotFound if no match.

func (*Repo) GetUserByUsername

func (r *Repo) GetUserByUsername(ctx context.Context, username string) (*User, error)

GetUserByUsername looks up a user by username. Returns ErrNotFound if no match.

func (*Repo) GetUserWithGrants

func (r *Repo) GetUserWithGrants(ctx context.Context, id ID) (*User, []Grant, error)

GetUserWithGrants loads a user and their active grants in one round-trip.

func (*Repo) GetWork

func (r *Repo) GetWork(ctx context.Context, id ID) (*Work, error)

GetWork fetches a work by primary key. The returned Work includes its cache (inlined display data). Returns ErrNotFound if no row exists.

func (*Repo) GetWorkByIdentifier

func (r *Repo) GetWorkByIdentifier(ctx context.Context, scheme, val string) (*Work, error)

GetWorkByIdentifier fetches the work that owns the given scheme:val identifier. Returns ErrNotFound if no match.

func (*Repo) GetWorkCollection

func (r *Repo) GetWorkCollection(ctx context.Context, id ID) (*WorkCollection, error)

func (*Repo) GetWorkCollectionBySlug

func (r *Repo) GetWorkCollectionBySlug(ctx context.Context, slug string) (*WorkCollection, error)

func (*Repo) GetWorkHistory

func (r *Repo) GetWorkHistory(ctx context.Context, workID ID) ([]WorkHistoryEntry, error)

GetWorkHistory returns the history for a work, grouped by field. For each field it shows:

  • The current value (from human edit or source)
  • If human-edited: the source value as a history entry
  • Previous human edits from bbl_log

func (*Repo) GetWorkRepresentation

func (r *Repo) GetWorkRepresentation(ctx context.Context, workID ID, scheme string) (*Representation, error)

func (*Repo) GetWorkWithEdits

func (r *Repo) GetWorkWithEdits(ctx context.Context, id ID) (*Work, map[string]FieldEdit, error)

GetWorkWithEdits fetches a work and its human edits in a single query.

func (*Repo) GetWorks

func (r *Repo) GetWorks(ctx context.Context, ids []ID) ([]*Work, error)

GetWorks fetches multiple works by ID, preserving the input order. Missing IDs are silently skipped.

func (*Repo) HasWorkCollection

func (r *Repo) HasWorkCollection(ctx context.Context, slug string) (bool, error)

func (*Repo) ImportOrganizations

func (r *Repo) ImportOrganizations(ctx context.Context, source string, seq iter.Seq2[*ImportOrganizationInput, error]) (int, error)

func (*Repo) ImportPeople

func (r *Repo) ImportPeople(ctx context.Context, source string, seq iter.Seq2[*ImportPersonInput, error]) (int, error)

func (*Repo) ImportProjects

func (r *Repo) ImportProjects(ctx context.Context, source string, seq iter.Seq2[*ImportProjectInput, error]) (int, error)

func (*Repo) ImportUsers

func (r *Repo) ImportUsers(ctx context.Context, source, authProvider, defaultRole string, seq iter.Seq2[*ImportUserInput, error]) (int, error)

ImportUsers runs a full sweep from seq, importing all records in batches. source is the source name stored on each record; authProvider is optional and, if non-empty, is attached to the user's auth providers on creation. defaultRole is assigned on user creation when the input record has no role set. Returns the number of successfully imported users and the first fatal error.

func (*Repo) ImportWorks

func (r *Repo) ImportWorks(ctx context.Context, source string, seq iter.Seq2[*ImportWorkInput, error]) (int, error)

ImportWorks runs a full sweep from seq, importing all records in batches. Re-import = delete all of this source's assertions for the entity + insert new ones. Returns the number of records that resulted in a create or update.

func (*Repo) ListPublicWorks

func (r *Repo) ListPublicWorks(ctx context.Context, opts ListPublicWorksOpts) (*ListPublicWorksResult, error)

ListPublicWorks returns a page of public works ordered by (updated_at, id) for keyset pagination.

func (*Repo) ListWorkCollectionItems

func (r *Repo) ListWorkCollectionItems(ctx context.Context, collectionID ID, offset, limit int) ([]*Work, int, error)

func (*Repo) ListWorkCollections

func (r *Repo) ListWorkCollections(ctx context.Context) ([]*WorkCollection, error)

func (*Repo) ListWorkItems

func (r *Repo) ListWorkItems(ctx context.Context, listID ID, statuses []string, offset, limit int) ([]*Work, int, error)

ListWorkItems returns a paginated slice of works from an items-based list, filtered by the given statuses. Returns the works and visible total count.

func (*Repo) ListWorkRepresentations

func (r *Repo) ListWorkRepresentations(ctx context.Context, opts ListRepresentationOpts) (*ListRepresentationResult, error)

func (*Repo) Pool

func (r *Repo) Pool() *pgxpool.Pool

func (*Repo) RemoveListItems

func (r *Repo) RemoveListItems(ctx context.Context, listID ID, items []ListItem) error

func (*Repo) RemoveWorkCollectionItems

func (r *Repo) RemoveWorkCollectionItems(ctx context.Context, collectionID ID, workIDs []ID) error

func (*Repo) SearchUsers

func (r *Repo) SearchUsers(ctx context.Context, query string, limit, offset int) (*UserHits, error)

SearchUsers performs a simple text search over users by name or username. Returns paginated results ordered by name.

func (*Repo) Update

func (r *Repo) Update(ctx context.Context, ops []Op, opts *UpdateOpts) (bool, []RevEffect, error)

Update executes a batch of human edits atomically.

Pipeline:

  1. Plan+gather: run each Op's plan() to produce RecordOps, then bulk-load all affected records (record rows, source assertions) in one batch per record type.
  2. Apply: run each RecordOp's pure Apply func against the in-memory RecordState. All edits (scalar and relational) store the value in Edits.
  3. Resolve: compute the winning value per field (source priority with human override).
  4. Prune: mark records where nothing changed as noop.
  5. Write: persist everything — lifecycle SQL, log entries, edits JSONB, and resolved fields.

func (*Repo) UpdateList

func (r *Repo) UpdateList(ctx context.Context, list *List) error

func (*Repo) UpdateWorkCollection

func (r *Repo) UpdateWorkCollection(ctx context.Context, c *WorkCollection) error

func (*Repo) UpsertSource

func (r *Repo) UpsertSource(ctx context.Context, id string) error

UpsertSource registers a source in bbl_sources if it does not already exist. All tables that reference bbl_sources require the source to be present first.

func (*Repo) UpsertWorkRepresentation

func (r *Repo) UpsertWorkRepresentation(ctx context.Context, workID ID, scheme string, record []byte, recordSHA256 []byte, revID int64) error

func (*Repo) UserLists

func (r *Repo) UserLists(ctx context.Context, userID ID) ([]*List, error)

type Representation

type Representation struct {
	RecordID  ID
	Scheme    string
	Record    []byte
	DeletedAt *time.Time
	RevID     int64
	UpdatedAt time.Time
}

type ResolvedRecord

type ResolvedRecord struct {
	*RecordState

	// Resolved fields (output of resolveFields).
	ResolvedFields map[string]json.RawMessage

	// Log entries collected from apply.
	LogEntries []LogEntry

	// Set by prune — if true, nothing changed for this record.
	Noop bool
	// contains filtered or unexported fields
}

ResolvedRecord is one record's post-resolution state.

type RevEffect

type RevEffect struct {
	RecordType string
	RecordID   ID
	RevID      int64
}

RevEffect describes a record affected by a revision.

type RevState

type RevState struct {
	Records    map[ID]*RecordState
	Priorities map[string]int
}

RevState is the pre-change state for all records in a revision.

type RoleConfig

type RoleConfig struct {
	Priority int
	Grants   []Grant
}

RoleConfig defines a configured role.

type SearchOpts

type SearchOpts struct {
	Query  string       `json:"query,omitempty"`
	Filter *QueryFilter `json:"filter,omitempty"`
	Facets []string     `json:"facets,omitempty"`
	Size   int          `json:"size"`
	Cursor string       `json:"cursor,omitempty"` // base64-encoded search_after; mutually exclusive with Offset
	Offset int          `json:"offset,omitempty"` // for UI pagination; hard max enforced by implementation
}

SearchOpts controls a search query.

func (*SearchOpts) WithFilter

func (o *SearchOpts) WithFilter(field string, terms ...string) *SearchOpts

WithFilter adds a terms filter to the search opts and returns them for chaining.

type Services

type Services struct {
	Repo            *Repo
	Index           Index
	FileStore       FileStore
	Catbird         *catbird.Client
	Authorizer      *Authorizer
	UserSources     map[string]UserSource
	WorkIterSources map[string]WorkSourceIter
	WorkGetSources  map[string]WorkSourceGetter
	WorkEncoders    map[string]WorkEncoder
}

Services bundles the core runtime dependencies.

func (*Services) BatchUpdateAndIndex

func (s *Services) BatchUpdateAndIndex(ctx context.Context,
	works iter.Seq2[*Work, error], buildOps func(*Work) []Op,
	user *User) (*BatchResult, error)

BatchUpdateAndIndex applies ops to each work from the iterator, one at a time. Each work is updated independently with OCC via its RevID.

func (*Services) ConfirmUpload

func (s *Services) ConfirmUpload(ctx context.Context, id ID, size int, sha256 string) error

ConfirmUpload verifies the blob exists in the store, then updates the staging row.

func (*Services) DeleteUpload

func (s *Services) DeleteUpload(ctx context.Context, id ID) error

DeleteUpload removes the staging row and deletes the blob from the store (best-effort).

func (*Services) DeleteWorkFile

func (s *Services) DeleteWorkFile(ctx context.Context, fileID ID, blobID string) error

DeleteWorkFile removes the DB row, then deletes the blob from the store (best-effort).

func (*Services) EachListWork

func (s *Services) EachListWork(ctx context.Context, list *List, statuses []string) iter.Seq2[*Work, error]

EachListWork iterates over all works in a list, filtered by statuses. Dispatches to the repo for items-based lists or the search index for query-based lists.

func (*Services) ImportOrganizationsAndIndex

func (s *Services) ImportOrganizationsAndIndex(ctx context.Context, source string, seq iter.Seq2[*ImportOrganizationInput, error]) (int, error)

ImportOrganizationsAndIndex imports organizations and best-effort indexes changed records.

func (*Services) ImportPeopleAndIndex

func (s *Services) ImportPeopleAndIndex(ctx context.Context, source, authProvider string, seq iter.Seq2[*ImportPersonInput, error]) (int, error)

ImportPeopleAndIndex imports people and best-effort indexes changed records.

func (*Services) ImportProjectsAndIndex

func (s *Services) ImportProjectsAndIndex(ctx context.Context, source string, seq iter.Seq2[*ImportProjectInput, error]) (int, error)

ImportProjectsAndIndex imports projects and best-effort indexes changed records.

func (*Services) ImportWorksAndIndex

func (s *Services) ImportWorksAndIndex(ctx context.Context, source string, seq iter.Seq2[*ImportWorkInput, error]) (int, error)

ImportWorksAndIndex imports works and best-effort indexes changed records. Uses a timestamp taken before the import to re-fetch changed works from the DB, because the in-memory Work during import doesn't have the cache populated.

func (*Services) ListWorks

func (s *Services) ListWorks(ctx context.Context, list *List, statuses []string, offset, limit int) ([]*Work, int, error)

ListWorks returns a paginated slice of works from a list, filtered by statuses. Dispatches to the repo for items-based lists or the search index for query-based lists.

func (*Services) SearchAllWorkRecords

func (s *Services) SearchAllWorkRecords(ctx context.Context, opts *SearchOpts) iter.Seq2[*Work, error]

SearchAllWorkRecords returns an iterator over full work records matching the query, using cursor-based pagination internally. Each page of index hits is batch-fetched from the repo.

func (*Services) SearchPublicWorkRecords

func (s *Services) SearchPublicWorkRecords(ctx context.Context, opts *SearchOpts) (*WorkRecordHits, error)

SearchPublicWorkRecords searches the index for public works and fetches full records.

func (*Services) SearchWorkRecords

func (s *Services) SearchWorkRecords(ctx context.Context, opts *SearchOpts) (*WorkRecordHits, error)

SearchWorkRecords searches the index and fetches full work records from the repo.

func (*Services) UpdateAndIndex

func (s *Services) UpdateAndIndex(ctx context.Context, ops []Op, opts *UpdateOpts) (bool, error)

UpdateAndIndex writes a revision to the DB and best-effort indexes affected records.

type Set

type Set struct {
	RecordType string `json:"record_type"`
	RecordID   ID     `json:"id"`
	Field      string `json:"field"`
	Val        any    `json:"val"`
}

Set asserts a value for a field.

type SetWorkReviewStatus

type SetWorkReviewStatus struct {
	WorkID       ID      `json:"id"`
	ReviewStatus *string `json:"review_status"`
}

SetWorkReviewStatus changes a work's review status.

type SetWorkStatus

type SetWorkStatus struct {
	WorkID ID     `json:"id"`
	Status string `json:"status"`
}

SetWorkStatus changes a work's status.

type TermsFilter

type TermsFilter struct {
	Field string   `json:"field"`
	Terms []string `json:"terms"`
}

TermsFilter matches documents where the field contains any of the given terms.

type Text

type Text struct {
	Lang string `json:"lang"`
	Val  string `json:"val"`
}

Text is a language-tagged string value (title, abstract, etc.).

type Title

type Title struct {
	Lang string `json:"lang"`
	Val  string `json:"val"`
}

Title is a language-tagged title (for projects, works, etc.). Separate from Text to allow future expansion (e.g. kind: translated_title).

type Unset

type Unset struct {
	RecordType string `json:"record_type"`
	RecordID   ID     `json:"id"`
	Field      string `json:"field"`
}

Unset withdraws the human edit for a field.

type UpdateOpts

type UpdateOpts struct {
	User *User // who is making the change (nil for system jobs)
	Rev  int64 // if non-zero, reject if any affected record has rev_id > this
}

UpdateOpts configures optional behavior for Update.

type Upload

type Upload struct {
	ID          ID        `json:"id"`
	UserID      ID        `json:"user_id"`
	BlobID      string    `json:"blob_id"`
	Name        string    `json:"name"`
	ContentType string    `json:"content_type"`
	Size        int       `json:"size"`
	SHA256      string    `json:"sha256,omitempty"`
	Status      string    `json:"status"`
	CreatedAt   time.Time `json:"created_at"`
}

type User

type User struct {
	ID            ID
	CreatedAt     time.Time
	Username      string
	Email         string
	Name          string
	Role          string
	DeactivateAt  *time.Time
	PersonID      *ID
	AuthProviders []AuthProvider
	Grants        *UserGrants // populated by Authorizer.Resolve; nil for imports/CLI
}

type UserAttrs

type UserAttrs struct {
	Username string
	Email    string
	Name     string
	Role     string
}

type UserGrants

type UserGrants struct {
	// contains filtered or unexported fields
}

UserGrants holds the resolved authorization state for a user in a request. Usable from both handlers and templates.

func (*UserGrants) Can

func (ug *UserGrants) Can(capability, scope string, isOwner bool, recordID *ID) bool

Check checks if the user has a capability on a scope.

func (*UserGrants) CanBatchEditWork

func (ug *UserGrants) CanBatchEditWork() bool

func (*UserGrants) CanCreateWork

func (ug *UserGrants) CanCreateWork() bool

func (*UserGrants) CanDeleteWork

func (ug *UserGrants) CanDeleteWork(work *Work) bool

func (*UserGrants) CanEditWork

func (ug *UserGrants) CanEditWork(work *Work) bool

func (*UserGrants) CanImpersonate

func (ug *UserGrants) CanImpersonate() bool

func (*UserGrants) CanManageUsers

func (ug *UserGrants) CanManageUsers() bool

func (*UserGrants) CanReviewWork

func (ug *UserGrants) CanReviewWork(work *Work) bool

func (*UserGrants) Outranks

func (ug *UserGrants) Outranks(role string) bool

Outranks returns true if this user's priority is >= the given role's priority. Used for the field assertion lock: a user can only overwrite a field asserted by an equal-or-lower priority role.

type UserHit

type UserHit struct {
	ID   ID
	Name string
	Role string
}

type UserHits

type UserHits struct {
	Hits  []*UserHit
	Total int
}

type UserIdentifier

type UserIdentifier struct {
	Scheme string `json:"scheme"`
	Val    string `json:"val"`
}

UserIdentifier is an auth claim identifier (e.g. scheme="ugent_id", val="abc123").

type UserSource

type UserSource interface {
	Iter(ctx context.Context) (iter.Seq2[*ImportUserInput, error], error)
}

UserSource is implemented by packages that stream user records from an external directory (LDAP, SCIM, CSV, …). Iter connects eagerly and returns a fatal setup error (e.g. connection refused, bad credentials) as the second return value. Per-entry errors are yielded inline so the caller can skip individual bad records without aborting the sweep.

type Work

type Work struct {
	ID           ID         `json:"id"`
	RevID        int64      `json:"rev_id"`
	CreatedAt    time.Time  `json:"created_at"`
	UpdatedAt    time.Time  `json:"updated_at"`
	CreatedByID  *ID        `json:"created_by_id,omitempty"`
	UpdatedByID  *ID        `json:"updated_by_id,omitempty"`
	Kind         string     `json:"kind"`
	Status       string     `json:"status"`
	ReviewStatus string     `json:"review_status,omitempty"` // empty = not in review
	DeleteKind   string     `json:"delete_kind,omitempty"`
	DeletedAt    *time.Time `json:"deleted_at,omitempty"`
	DeletedByID  *ID        `json:"deleted_by_id,omitempty"`
	MadePublicAt *time.Time `json:"made_public_at,omitempty"`
	Files        []WorkFile `json:"files,omitempty"`
	// contains filtered or unexported fields
}

func (*Work) Abstracts

func (w *Work) Abstracts() []Text

func (*Work) ArticleNumber

func (w *Work) ArticleNumber() string

Builtin field getters.

func (*Work) BookTitle

func (w *Work) BookTitle() string

func (*Work) Classifications

func (w *Work) Classifications() []Identifier

func (*Work) Conference

func (w *Work) Conference() Conference

func (*Work) Contributors

func (w *Work) Contributors() []WorkContributor

func (*Work) Edition

func (w *Work) Edition() string

func (*Work) Identifiers

func (w *Work) Identifiers() []Identifier

func (*Work) Issue

func (w *Work) Issue() string

func (*Work) IssueTitle

func (w *Work) IssueTitle() string

func (*Work) JournalAbbreviation

func (w *Work) JournalAbbreviation() string

func (*Work) JournalTitle

func (w *Work) JournalTitle() string

func (*Work) Keywords

func (w *Work) Keywords() []Keyword

func (*Work) LaySummaries

func (w *Work) LaySummaries() []Text

func (*Work) MarshalJSON

func (w *Work) MarshalJSON() ([]byte, error)

func (*Work) Notes

func (w *Work) Notes() []Note

func (*Work) Organizations

func (w *Work) Organizations() []ID

func (*Work) Pages

func (w *Work) Pages() Extent

func (*Work) PlaceOfPublication

func (w *Work) PlaceOfPublication() string

func (*Work) Projects

func (w *Work) Projects() []ID

func (*Work) PublicationStatus

func (w *Work) PublicationStatus() string

func (*Work) PublicationYear

func (w *Work) PublicationYear() string

func (*Work) Publisher

func (w *Work) Publisher() string

func (*Work) Rels

func (w *Work) Rels() []WorkRel

func (*Work) ReportNumber

func (w *Work) ReportNumber() string

func (*Work) SeriesTitle

func (w *Work) SeriesTitle() string

func (*Work) StringField

func (w *Work) StringField(name string) string

Dynamic access for extensible field types.

func (*Work) TextField

func (w *Work) TextField(name string) []Text

func (*Work) Titles

func (w *Work) Titles() []Title

func (*Work) TotalPages

func (w *Work) TotalPages() string

func (*Work) Volume

func (w *Work) Volume() string

type WorkCollection

type WorkCollection struct {
	ID          ID         `json:"id"`
	Version     int        `json:"version"`
	Slug        string     `json:"slug"`
	Name        string     `json:"name"`
	Description string     `json:"description,omitempty"`
	Query       *ListQuery `json:"query,omitempty"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
}

func (*WorkCollection) IsQuery

func (c *WorkCollection) IsQuery() bool

type WorkContributor

type WorkContributor struct {
	Kind       string   `json:"kind,omitempty"` // "person" (default) or "organization"
	PersonID   *ID      `json:"person_id,omitempty"`
	Name       string   `json:"name,omitempty"`
	GivenName  string   `json:"given_name,omitempty"`
	FamilyName string   `json:"family_name,omitempty"`
	Roles      []string `json:"roles,omitempty"`
}

WorkContributor is a contributor in the resolved fields.

type WorkDecoder

type WorkDecoder interface {
	Decode(data []byte) (*ImportWorkInput, error)
}

WorkDecoder decodes a single work import record from bytes.

func NewWorkDecoder

func NewWorkDecoder(format string) (WorkDecoder, error)

NewWorkDecoder creates a new decoder for the given format.

type WorkEncoder

type WorkEncoder interface {
	Encode(work *Work) ([]byte, error)
}

WorkEncoder encodes a single work into a self-contained document.

func NewWorkEncoder

func NewWorkEncoder(format string) (WorkEncoder, error)

NewWorkEncoder creates a new encoder for the given format.

type WorkFile

type WorkFile struct {
	ID                 ID         `json:"id"`
	WorkID             ID         `json:"work_id,omitempty"`
	Position           int        `json:"position,omitempty"`
	BlobID             string     `json:"blob_id"`
	Name               string     `json:"name"`
	ContentType        string     `json:"content_type"`
	Size               int        `json:"size"`
	SHA256             string     `json:"sha256,omitempty"`
	AccessLevel        string     `json:"access_level"`
	EmbargoUntil       *time.Time `json:"embargo_until,omitempty"`
	EmbargoAccessLevel string     `json:"embargo_access_level,omitempty"`
	EmbargoLiftedAt    *time.Time `json:"embargo_lifted_at,omitempty"`
}

type WorkHistoryEntry

type WorkHistoryEntry struct {
	RevID     int64
	RevAt     time.Time
	Field     string
	Op        string // 'set', 'hide', 'unset'
	Val       json.RawMessage
	By        string // user name or source name
	IsHistory bool   // true = previous value, false = current
}

WorkHistoryEntry is one entry in the history view for a work.

type WorkHit

type WorkHit struct {
	ID     ID     `json:"id"`
	Kind   string `json:"kind"`
	Status string `json:"status"`
	Title  string `json:"title"`
}

WorkHit is a search result for a work, containing display fields.

type WorkHits

type WorkHits struct {
	Hits   []WorkHit `json:"hits"`
	Total  int       `json:"total"`
	Cursor string    `json:"cursor,omitempty"`
	Facets []Facet   `json:"facets,omitempty"`
}

WorkHits is the result of a work search query.

func SearchPublicWorks

func SearchPublicWorks(ctx context.Context, idx WorkIndex, opts *SearchOpts) (*WorkHits, error)

SearchPublicWorks searches for works with status=public.

type WorkIndex

type WorkIndex interface {
	Add(ctx context.Context, work *Work) error
	Delete(ctx context.Context, id ID) error
	DeleteAll(ctx context.Context) error
	Search(ctx context.Context, opts *SearchOpts) (*WorkHits, error)
	Reindex(ctx context.Context, all iter.Seq2[*Work, error], changed func(since time.Time) iter.Seq2[*Work, error]) error
}

WorkIndex is the search index for works.

type WorkReader

type WorkReader interface {
	Read(r io.Reader) iter.Seq2[*ImportWorkInput, error]
}

WorkReader reads a stream of work import records from a reader.

func NewWorkReader

func NewWorkReader(format string) (WorkReader, error)

NewWorkReader creates a new reader for the given format.

type WorkRecordHit

type WorkRecordHit struct {
	Work *Work `json:"work"`
}

WorkRecordHit is a search result containing the full work record.

type WorkRecordHits

type WorkRecordHits struct {
	Hits   []WorkRecordHit `json:"hits"`
	Total  int             `json:"total"`
	Cursor string          `json:"cursor,omitempty"`
	Facets []Facet         `json:"facets,omitempty"`
}

WorkRecordHits is the result of a work search with full records.

type WorkRel

type WorkRel struct {
	RelatedWorkID ID     `json:"related_work_id"`
	Kind          string `json:"kind"`
}

WorkRel links two works with a typed relationship.

type WorkSourceGetter

type WorkSourceGetter interface {
	Get(ctx context.Context, id string) (*ImportWorkInput, error)
}

WorkSourceGetter is implemented by sources that can fetch a single record by ID.

type WorkSourceIter

type WorkSourceIter interface {
	Iter(ctx context.Context) (iter.Seq2[*ImportWorkInput, error], error)
}

WorkSourceIter is implemented by sources that can iterate all records.

type WorkWriter

type WorkWriter interface {
	Begin(w io.Writer) error
	Encode(w io.Writer, work *Work) error
	End(w io.Writer) error
}

WorkWriter writes a stream of works to a writer. Begin writes any preamble (e.g. CSV header, XML root open tag). Encode writes a single work. End writes any postamble (e.g. XML root close tag).

func NewWorkWriter

func NewWorkWriter(format string) (WorkWriter, error)

NewWorkWriter creates a new writer for the given format.

Directories

Path Synopsis
app
views
templ: version: v0.3.1001
templ: version: v0.3.1001
Package citeformat formats works as citations using a citeproc-js-server.
Package citeformat formats works as citations using a citeproc-js-server.
cmd
bbl command
Package csvformat encodes works as CSV.
Package csvformat encodes works as CSV.
Package dcformat encodes works as Dublin Core XML.
Package dcformat encodes works as Dublin Core XML.
Package oidcauth implements app.AuthProvider using OpenID Connect.
Package oidcauth implements app.AuthProvider using OpenID Connect.
Package sru implements a minimal SRU (Search/Retrieve via URL) 1.2 server.
Package sru implements a minimal SRU (Search/Retrieve via URL) 1.2 server.
ugent
cmd/bbl command

Jump to

Keyboard shortcuts

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