files

package
v1.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package files is the service / use-case layer. It owns the write path and the consistency logic between storage and metadata; layers above it (api) talk only to this package and never reach storage or metadata directly.

Index

Constants

View Source
const (
	JobPending   = metadata.JobPending
	JobRunning   = metadata.JobRunning
	JobSucceeded = metadata.JobSucceeded
	JobFailed    = metadata.JobFailed
	JobCancelled = metadata.JobCancelled
)

Job lifecycle states.

View Source
const (
	EntriesInclude = metadata.EntriesInclude
	EntriesExclude = metadata.EntriesExclude
	EntriesOnly    = metadata.EntriesOnly
)

List scopes, re-exported for the api layer.

View Source
const ReservedTagPrefix = metadata.ReservedTagPrefix

ReservedTagPrefix marks metadata keys the service writes and clients may not: the api layer rejects them on upload and PATCH, and the repository carries them across a replace.

Variables

View Source
var ErrArchiveTooLarge = errors.New("files: archive exceeds the extraction limits")

ErrArchiveTooLarge means the archive, or the selection to extract from it, exceeds a configured cap. The api layer maps it to 413; the wrapped message names the cap and the numbers.

View Source
var ErrChecksumMismatch = errors.New("files: checksum mismatch")

ErrChecksumMismatch is surfaced by a verifying download reader when the stored bytes no longer hash to the recorded checksum (Phase 2 integrity).

View Source
var ErrExtractCancelled = errors.New("files: extraction cancelled")

ErrExtractCancelled is returned by Extract when its progress hook asked it to stop. The partial summary is returned alongside it: the children written so far remain, and a later run resumes past them.

View Source
var ErrExtractInProgress = errors.New("files: an extraction of this archive is already in progress")

ErrExtractInProgress means another extraction job for the same archive is still pending or running. The api layer maps it to 409; the value returned is an *ExtractInProgressError naming that job, which is the one to poll.

View Source
var ErrIdempotencyConflict = errors.New("files: idempotency key conflict")

ErrIdempotencyConflict means an upload with the same Idempotency-Key is still in progress. The api layer maps it to 409 — the client should retry shortly.

View Source
var ErrIdempotencyResultGone = errors.New("files: idempotent result deleted")

ErrIdempotencyResultGone means the file an Idempotency-Key produced has since been deleted. Retrying under the same key can never succeed until the key expires, so the api layer maps it to 410 — the client must pick a new key.

View Source
var ErrInvalidID = errors.New("files: invalid id")

ErrInvalidID is returned when an id is not a well-formed file identifier. The api layer maps it to 400 — a malformed id is a client error, not a 500.

View Source
var ErrInvalidSelection = errors.New("files: entry selection out of range")

ErrInvalidSelection means a requested entry index is outside the archive's directory. The api layer maps it to 400.

View Source
var ErrJobNotFound = errors.New("files: extraction job not found")

ErrJobNotFound is returned when no extraction job matches — including one the retention sweep has already purged. The api layer maps it to 404.

View Source
var ErrNotArchive = errors.New("files: not a supported archive (zip)")

ErrNotArchive means the file the archive endpoints were pointed at is not a supported archive. The api layer maps it to 415 — the one status a caller will hit by accident, so it is named rather than left to a generic 400.

View Source
var ErrNotFound = errors.New("files: not found")

ErrNotFound is returned when no live file matches. It is the api layer's only signal for a 404, keeping storage/metadata sentinels below this seam.

Functions

This section is empty.

Types

type ArchiveEntry added in v1.3.0

type ArchiveEntry = archive.Entry

ArchiveEntry and SkipReason are aliased so the api layer can describe an archive without importing the archive package directly.

type ArchiveLimits added in v1.3.0

type ArchiveLimits struct {
	MaxEntryBytes int64    // per extracted entry — an entry is an upload, so this reuses the upload cap
	MaxEntries    int      // entries in the archive's directory; both endpoints refuse larger archives
	MaxTotalBytes int64    // uncompressed bytes one extraction may write
	MaxRatio      int64    // per-entry uncompressed ÷ compressed
	Extensions    []string // allowlist, lower-case with the leading dot; empty allows all

	// MaxDirectoryBytes bounds the central directory's span, refusing an
	// archive before its directory is parsed. It is the only cap applied
	// pre-parse — MaxEntries cannot be, since the count does not exist until
	// the parse is done. Zero does not disable it: it derives the bound from
	// the entry ceiling instead (see maxRecordBudget).
	MaxDirectoryBytes int64
}

ArchiveLimits bound an extraction. Zero disables each cap.

type EntryScope added in v1.3.0

type EntryScope = metadata.EntryScope

EntryScope and its values select extracted archive entries in a List.

type ExtractInProgressError added in v1.3.0

type ExtractInProgressError struct {
	JobID string // may be empty if that job finished between the refusal and the lookup
}

ExtractInProgressError is ErrExtractInProgress carrying the id of the job that holds the archive, so the api layer can tell the caller which job to poll rather than only when to try again.

func (*ExtractInProgressError) Error added in v1.3.0

func (e *ExtractInProgressError) Error() string

func (*ExtractInProgressError) Unwrap added in v1.3.0

func (e *ExtractInProgressError) Unwrap() error

Unwrap makes errors.Is(err, ErrExtractInProgress) true.

type ExtractOptions added in v1.3.0

type ExtractOptions struct {
	// Entries restricts the run to those directory indexes (an index outside
	// the directory is ErrInvalidSelection); nil means every selectable
	// entry, and an empty, non-nil slice selects nothing.
	Entries []int

	// OnProgress, when set, is called once before the first entry with
	// (0, total) and again after each entry. Returning true stops the run
	// after the entry just written — Extract then returns the partial summary
	// with ErrExtractCancelled. The hook is what lets a job report progress
	// and honour cancellation without this package knowing what a job is.
	OnProgress func(done, total int) (cancel bool)
}

ExtractOptions configures one extraction run.

type ExtractSummary added in v1.3.0

type ExtractSummary struct {
	ArchiveID       string             `json:"archive_id"`
	Entries         int                `json:"entries"` // entries in the archive's directory
	Extracted       int                `json:"extracted"`
	Skipped         int                `json:"skipped"`
	SkippedByReason map[SkipReason]int `json:"skipped_by_reason"`
	SampleSkipped   []SkippedEntry     `json:"sample_skipped"` // the first sampleSkippedMax skipped entries
}

ExtractSummary is the bounded report of one extraction. It never lists the files it created: a large archive's results could not fit one response, so callers enumerate the children with a tag filter on the archive id instead.

The JSON tags are the wire shape — the api layer serialises it as is, and a job stores the same bytes — so there is one shape, not two.

type File

type File = metadata.File

File is the service-level view of a stored file, returned to the api layer.

type Job added in v1.3.0

type Job = metadata.Job

Job is the service-level view of an extraction job.

type JobStatus added in v1.3.0

type JobStatus = metadata.JobStatus

JobStatus and its values, re-exported for the api layer.

type ListFilter

type ListFilter = metadata.ListFilter

ListFilter narrows a List query. Aliased so the api layer can build filters without importing the metadata package directly.

type Option added in v1.3.0

type Option func(*Service)

Option configures a Service beyond its two collaborators.

func WithArchiveLimits added in v1.3.0

func WithArchiveLimits(l ArchiveLimits) Option

WithArchiveLimits sets the extraction caps. Without it extraction is uncapped.

type Repository added in v1.3.0

type Repository interface {
	metadata.Repository
	metadata.JobRepository
}

Repository is everything the service persists through: file records and extraction jobs. The two are separate seams in the metadata package; one pgx repository — and one in-memory double — satisfies both.

type Service

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

Service orchestrates the object store and the metadata repository.

func New

func New(backend storage.Backend, repo Repository, opts ...Option) *Service

New wires the service to its collaborators.

func (*Service) ArchiveEntries added in v1.3.0

func (s *Service) ArchiveEntries(ctx context.Context, id string) (*File, []ArchiveEntry, error)

ArchiveEntries lists the entries of the archive stored as id, each classified as selectable or skipped with a reason. It writes nothing, and it is what lets a caller answer "which file exactly?" before anything is stored.

func (*Service) CancelExtraction added in v1.3.0

func (s *Service) CancelExtraction(ctx context.Context, jobID string) (*Job, error)

CancelExtraction asks the job to stop and returns its state after the request. A pending job is cancelled at once; a running one stops after the entry it is writing, keeping the children written so far; a terminal one is unchanged — cancelling is idempotent and never an error on its own.

func (*Service) Delete

func (s *Service) Delete(ctx context.Context, id string) error

Delete soft-deletes the file: the row is stamped deleted_at and immediately vanishes from reads (every read path filters deleted_at is null), while the object is purged asynchronously by GC (PLAN §7). Returns ErrNotFound if the file does not exist.

func (*Service) Extract added in v1.3.0

func (s *Service) Extract(ctx context.Context, id string, o ExtractOptions) (*ExtractSummary, error)

Extract stores every selected entry of the archive id as an ordinary file, tagged with the archive it came from.

A run is resumable by construction: entries that already have a child row are skipped as SkipAlreadyExtracted, so a run interrupted part-way — or simply repeated — completes the remainder instead of duplicating. A child the user deleted stays deleted (SkipPreviouslyDeleted). On any error, or a cancellation through OnProgress, the children written so far remain, and the next run picks up after them. Serialising concurrent runs on one archive is the job layer's concern: nothing here prevents two direct callers from writing the same archive at once.

func (*Service) ExtractionJob added in v1.3.0

func (s *Service) ExtractionJob(ctx context.Context, jobID string) (*Job, error)

ExtractionJob returns the job by id, or ErrJobNotFound — which a job past retention also answers, since the sweep deletes its row.

func (*Service) List

func (s *Service) List(ctx context.Context, filter ListFilter) ([]*File, error)

List returns file records matching the filter, newest first. The metadata repository applies tag containment against the GIN index and keyset pagination by id. A malformed cursor is ErrInvalidID — it must be a file id handed out by a previous page.

func (*Service) Metadata

func (s *Service) Metadata(ctx context.Context, id string) (*File, error)

Metadata returns the record for id, or ErrNotFound.

func (*Service) Open

func (s *Service) Open(ctx context.Context, id string) (*File, io.ReadCloser, error)

Open streams the whole object through this process, verifying the SHA-256 as it reads: the returned reader yields ErrChecksumMismatch at EOF if the stored bytes no longer match the recorded checksum. The caller must Close it.

func (*Service) OpenRange

func (s *Service) OpenRange(ctx context.Context, f *File, offset, length int64) (io.ReadCloser, error)

OpenRange streams a byte range [offset, offset+length) of an already-fetched record through this process — the caller resolves f via Metadata first (it needs the size to validate the range), so no second lookup happens here. Partial reads cannot be checksum-verified against the whole-object hash, so the reader is returned unverified. The caller must Close it.

func (*Service) Presign

func (s *Service) Presign(ctx context.Context, id string, ttl time.Duration) (*File, string, error)

Presign returns the record and a time-limited URL that serves the object directly from the store, offloading the transfer from this process.

func (*Service) SubmitExtraction added in v1.3.0

func (s *Service) SubmitExtraction(ctx context.Context, id string, selected []int) (*Job, error)

SubmitExtraction enqueues an extraction of the archive id and returns the pending job. The run is validated first, exactly as a synchronous extraction would — the archive opens, the selection is in range, the total is under the cap — so every refusal a caller could act on is made now, with the same errors, rather than surfacing later as a failed job. A job for an archive that already has one pending or running is refused with an *ExtractInProgressError naming it: one active extraction per archive is a database invariant, and the caller's next move is to poll that job.

func (*Service) UpdateMetadata

func (s *Service) UpdateMetadata(ctx context.Context, id string, tags map[string]any, merge bool) (*File, error)

UpdateMetadata writes tags onto a file's JSONB metadata, merging into the existing object or replacing it wholesale. Returns ErrNotFound if absent.

func (*Service) Upload

func (s *Service) Upload(ctx context.Context, in UploadInput) (*File, error)

Upload streams the body to storage, computing its SHA-256 inline, then commits the metadata row. On a metadata failure the freshly-written object is best-effort deleted; a leftover is reclaimed by GC.

func (*Service) UploadIdempotent

func (s *Service) UploadIdempotent(ctx context.Context, key string, in UploadInput) (f *File, replayed bool, err error)

UploadIdempotent performs an upload guarded by a client-supplied idempotency key. The first call for a key uploads and records the result; a retry returns the original result (replayed=true) instead of creating a duplicate. A retry while the first is still in flight — or whose result has since been deleted — returns ErrIdempotencyConflict (the client should retry shortly).

On the replay/conflict paths the request body is intentionally not read; the HTTP server drains it.

type SkipReason added in v1.3.0

type SkipReason = archive.SkipReason

ArchiveEntry and SkipReason are aliased so the api layer can describe an archive without importing the archive package directly.

const (
	SkipAlreadyExtracted  SkipReason = "already_extracted"  // a live child row exists for this index
	SkipPreviouslyDeleted SkipReason = "previously_deleted" // the child was soft-deleted: delete wins over re-extract
	SkipNotSelected       SkipReason = "not_selected"       // absent from the caller's entries list
	SkipCorrupt           SkipReason = "corrupt"            // the content failed the archive's own checks
)

Skip reasons decided by the service rather than by the archive's directory.

type SkippedEntry added in v1.3.0

type SkippedEntry struct {
	Index  int        `json:"index"`
	Name   string     `json:"name"`
	Reason SkipReason `json:"reason"`
}

SkippedEntry names one skipped entry and why.

type UploadInput

type UploadInput struct {
	Filename    string
	ContentType string
	Size        int64
	Tags        map[string]any
	Body        io.Reader
}

UploadInput carries the request-derived attributes of an upload. Size is the known content length, or -1 if unknown (resolved from storage after write).

Jump to

Keyboard shortcuts

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