Adding a package format
Read this page if you add support for a new package format, for example a new language registry. It lists every place a format touches, in order, and walks through a worked example.
A format is one registration plus its handler and its store. Nothing else enumerates formats. The generic engines (listing, search, trash, quota, cleanup, tiering, promote, backup, recovery, federation) read the generic tables packages, package_versions and package_files, and the format's storage.Storage descriptor. There is no table list, union branch, trigger or exhaustiveness test to extend. Docker is the only format with dedicated tables and is not a template.
The checklist
Run go test ./... after each item. The tests that remain name every place that still lacks the format: TestRegistrationsMatchSpecs, TestSQLFormatLists, TestOpenAPIFormatEnums, TestSharedRouteClashesAreKnown, the upload-label tests and the web tests.
Registry. One
Specininternal/repotype/repotype.go, in alphabetical order. It holds proxy, group and federation support, the default upstream, signing,Mount, version rules, PURL type and OSV ecosystem. Add the upload routes ininternal/repotype/uploads.go. These files are the only place format facts live; never add a slice orswitchof format names.Database constraint. Two migrations in
migrations/:NNN_<format>.sqldrops and re-addschk_repo_formatasNOT VALID, andNNN_<format>_validate.no_tx.sqlrunsVALIDATE CONSTRAINT.NNNis the next free number: runls migrationsand add one to the highest. Also add the format to the enums ofinternal/api/openapi.json. No table: the format lives in the generic tables.Handler.
internal/format/<format>/holds the protocol routes, publish, download and tests. Write it against a small store interface.Typed store. The files are:
internal/database/pkg_<format>.go: reads and writes throughwritePackageanddeleteFilesinpkg_generic.go. Its doc comment states the mapping topackages,package_versions,package_filesand theunit_key.internal/database/queries/<format>.sql, thenmake generate.internal/database/models/<format>.go.- The embed in
internal/database/stores.go, plus the interface ininternal/format/formattestif its fake store is used.
Add a side table only when a foreign key, an index or bulk data demands one.
Registration.
internal/formats/<format>.gois aplugin.Format, usually aplugin.Definition. It holds the hosted constructor, analysis hooks, the client-setup builder, proxy and group hooks (orNoProxyandNoGroup), lifecycle hooks, and thestorage.Storagedescriptor with its trash kind, cleanup partition, federation, detail, promotion and backup values. Add one line toregistrations()ininternal/formats/formats.go; the order there is the shared-route precedence.Client setup. Add the builder in
internal/clientsetup/clientsetup.go(snippets and base URL template).make generaterewritesweb/src/lib/formats.generated.tsandclientsetup.generated.tsfrom the registry and this package withcmd/genformats. CI fails when a committed file is stale.Web. Add
FORMAT_METAinweb/src/lib/formats.ts(label, colour, icon;bun run typecheckfails until it exists) and the install command inweb/src/lib/installCommands.ts. TouchPackageUploadZone.vuewhen the format has no browser upload.Metrics label. Add
Format<Name>ininternal/metrics/uploads.go.TestEveryFormatHasUploadLabelmaps every format to one.Client smoke test. Add
scripts/smoke/<format>.sh: publish a tiny package with the real client and install it back. See client compatibility matrix. The nightly matrix picks the script up by itself.Docs. Add
docs/formats/<format>.mdand a row indocs/formats/README.md.make docs-genrefreshesdocs/reference.
Optional, only when the format needs it:
models.TrashKind<Name>, when the API names the kind.internal/presign/paths.go, for the presigned blob paths of a proxy.- The
oa importsource:internal/oacli/import_fmt_<format>.goandimport_source.go. internal/oacli/cmd_ci_upload.go, foroa ci upload.restUploadHandlers()incmd/server/app_routes.go, for a REST upload endpoint ininternal/api.
Worked example: a hypothetical swift
The example is the Swift Package Registry, hosted only. Assume one package per scope.name, one version per release, one source archive file per version, and no proxy, group or federation. NNN stands for the next free migration number.
| # | File | What goes in it |
|---|---|---|
| 1 | internal/repotype/repotype.go | {Name: "swift", PURLType: "swift", OSVEcosystem: "SwiftURL", Mount: MountIsolated} |
| 1 | internal/repotype/uploads.go | The PUT /repos/{repo}/{scope}/{name}/{version} route as an upload endpoint |
| 2 | migrations/NNN_swift.sql | Drop and re-add chk_repo_format with 'swift' in the list, NOT VALID |
| 2 | migrations/NNN_swift_validate.no_tx.sql | ALTER TABLE repositories VALIDATE CONSTRAINT chk_repo_format; |
| 2 | internal/api/openapi.json | "swift" in each format enum |
| 3 | internal/format/swift/http.go, publish.go, *_test.go | The registry protocol: GET /{scope}/{name}, .../{version}, .../{version}.zip, Package.swift |
| 4 | internal/database/pkg_swift.go | PutSwiftRelease, GetSwiftRelease, ListSwiftReleases (see below) |
| 4 | internal/database/queries/swift.sql, models/swift.go, stores.go | The format-specific reads, the model, *SwiftStore embedded in PostgresDB |
| 5 | internal/formats/swift.go | plugin.Definition{ID: "swift", NewHosted: ..., Snippets: ..., Store: storage.Storage{Trash: ..., Detail: ...}} |
| 5 | internal/formats/formats.go | swiftFormat(), in registrations() |
| 6 | internal/clientsetup/clientsetup.go | The swift package-registry set snippet |
| 7 | web/src/lib/formats.ts, installCommands.ts | Label, colour, icon and the swift package add command |
| 8 | internal/metrics/uploads.go | FormatSwift = "swift" |
| 9 | scripts/smoke/swift.sh | Publish with swift package-registry publish, resolve it back |
| 10 | docs/formats/swift.md, docs/formats/README.md | The user page and the row |
The store functions in item 4 write these rows:
- One
packagesrow:name = scope.name,name_keyis the lower-cased name. - One
package_versionsrow:version_key = version. - One
package_filesrow:path = scope/name/version.zip,role = primary,listed. - A duplicate release maps to
ErrDuplicate.
That is 10 checklist items over 21 files. make generate also rewrites dbsqlc/, formats.generated.ts and clientsetup.generated.ts; those are not counted.
Nothing in internal/search, internal/trash, internal/backup, internal/promote, internal/federation, internal/recovery, internal/worker or the catalog and listing queries changes. Adding swift to them is the descriptor in item 5.
For proxy and group support, write the format's grammar in pkg/<format>, then its proxy hooks (internal/proxy/<format>.go) and merge hook (internal/group/<format>.go). Do not copy the resolve, revalidate and read sequence; see proxy and group pipeline.
The row journal
A format that stores its files in the generic package tables needs no journal entry of its own: its descriptor's SideTables are added to the registry automatically (NewRegistry().Tables()), and packages, package_versions, package_files and package_tags are already journaled. Any other table with a foreign key to blobs or repositories needs an entry in internal/recovery/tables.go (Tables, parents first) and a recovery_journal trigger in a new forward-only migration, or an entry with a reason in NotJournaled. TestRegistryCoversSchema fails otherwise. See .agents/rules/recovery-journal.md and Rebuild from storage.