File DB & entries
The file DB maps a build-relative path → entry (or a removal). It has three interchangeable storage backends — every command works the same regardless of which is used, because they all load to the same shape.
Backends
| Format | Extensions | Model |
|---|---|---|
jsonl (default) |
.jsonl, .ndjson |
append-only JSON Lines, last record per path wins |
yaml |
.yaml, .yml |
append-only YAML: a single mapping, appended key by key; last duplicate key wins; needs pip install 'pkgforge[yaml]' |
sqlite |
.db, .sqlite, .sqlite3 |
a real SQLite store, upserted in place |
The backend is --db-format (or PKGFORGE_DB_FORMAT) when given, else the
--db file's extension, else jsonl. The file's content is never
inspected to choose: a file that is not in the chosen format (a YAML file named
.jsonl, a text file named .db) fails to load with a DbError that names the
format and suggests --db-format. A flow-style YAML file ({...}) must be
compacted (pkgforge --db FILE compact) before appending.
jsonl and yaml files are UTF-8, with a leading byte-order mark ignored; a
malformed one raises DbError naming the file (and line). JSON Lines records
split on \n only.
Every backend enforces the same record schema when it loads, and coerces or
defaults nothing: a path is a string starting with /; a record has exactly
the entry fields below: mode, owner, group, caps and
package (strings), type (file, directory or symlink), kind (one of the
six kinds) and meta (string to string); a removal is {"path": ..., "_removed": true}
(jsonl), null (yaml) or a flagged row (sqlite). Anything else raises
DbError naming the file and the path, so a bad DB is one error at load time
instead of a crash while rendering. Reading a sqlite DB never writes to it: an empty or
schema-less file loads as an empty DB, and one with other tables but no
entries table (not a pkgforge DB) raises DbError instead of getting a
table added to it. A path or a field value that is not valid UTF-8 (an
undecodable file name, or a pwd/grp entry with one) is rejected with
DbError; jsonl/yaml store such a name as an escaped string instead.
# jsonl
{"caps": "cap_net_bind_service=ep", "group": "root", "kind": "file", "meta": {}, "mode": "755", "owner": "root", "package": "tool", "path": "/usr/bin/tool", "type": "file"}
{"caps": "-", "group": "adm", "kind": "config-noreplace", "meta": {}, "mode": "640", "owner": "root", "package": "tool", "path": "/etc/tool/config", "type": "file"}
The JSON Lines default is fast to write and parse and stays greppable —
grep '"/usr/bin/tool"' files.jsonl finds every record for a path. SQLite suits
very large DBs or when you want to query with SQL; YAML is the most
human-editable.
Compaction
The jsonl and yaml backends are append logs: a path installed twice leaves
two records (the later wins) and a removal leaves a tombstone. The
compact command rewrites the log with one record per live path.
The sqlite backend upserts in place, but still records a removal as a
flagged row rather than deleting it outright; compact deletes those rows
and VACUUMs to reclaim the space. initdb starts a fresh, empty DB in any
backend.
compact writes a temp file beside the DB and renames it into place, so the
directory must be writable; a failure (a full disk, a kill) leaves the
original DB unchanged instead of truncated or torn. On POSIX, writers
(install, scan) and compact flock the DB file, so compact can run
safely alongside another process appending to the same DB. A symlinked
--db keeps its link (its target is what gets replaced); a hardlinked one
is detached from the hardlink by the next compact.
Adding a backend
Backends are pluggable. A third-party package adds its own by subclassing
DbProvider with its own NAME and advertising it in the pkgforge.dbs
entry-point group — core pkgforge never needs to know about it:
import pkgforge
class TomlDb(pkgforge.DbProvider):
NAME = "toml"
SUFFIXES = (".toml",) # infer from a --db suffix
def load(self): ...
def add(self, path, entry): ...
def remove(self, path): ...
def compact(self): ...
def init(self): ...
# the plugin's pyproject.toml
[project.entry-points."pkgforge.dbs"]
toml = "tomlplug:TomlDb" # name = module:Class, the name equal to NAME
Once the package is installed, the format is selectable with
--db-format toml and by a .toml --db suffix, with no import in the
calling script. A DbProvider is constructed as provider_cls(path) and must
implement load/add/remove/compact/init; load() returns
{path: entry-or-None} like the built-ins. Look one up directly with
DbProvider.get("toml"), or list every name with DbProvider.names() (which
includes advertised backends without importing them). A script can also
define the subclass itself: defining it registers it for that process.
A NAME, alias or suffix that another backend already holds is a
ValueError when the class is created, and the new class is not registered.
An advertised backend that fails to import, or whose entry point does not
name a DbProvider subclass with that NAME, stops the command with an error
naming the entry point; while inferring a format from a suffix it is logged as
a warning and skipped.
A SUFFIXES entry without a leading dot gets one ("toml" registers the same
as ".toml"); a multi-dot suffix (e.g. ".tar.gz") is refused (raises
ValueError when the class is created), since it could never match a --db
path's suffix — and, like an invalid NAME/ALIASES clash, leaves the class
entirely unregistered rather than half-registered.
batch() is optional: a context manager yielding the provider around a run
of many writes (e.g. scan's walk); the default does nothing. sqlite
overrides it to hold one connection open and commit every 1000 rows instead
of connecting and committing once per write, which is what makes scan
into a sqlite DB fast; the default is fine for a backend that doesn't need
this (an append-log provider's own add/remove already write immediately
either way).
Entry fields
| Field | Meaning |
|---|---|
mode |
octal permission string, e.g. "644" |
owner / group |
user / group name |
type |
file, directory, or symlink |
kind |
how packaging treats the entry: file, config, config-noreplace, doc, license or ghost; set with -k/--kind or a rule, never - in a DB |
caps |
file capabilities for the package to set on install (one setcap clause), or - for none; set with --caps or a rule |
package |
the package the entry ships in: the [package] name or a [[packages]] name, or - when the configuration has no [package]; set with -P/--package or a rule, else the [package] name |
meta |
free-form string map (e.g. a symlink target), set with -O/--meta KEY=VALUE |
A yaml DB's scalars load as strings (mode: 0755 is "0755").
A record has exactly these eight fields. A DB written before package
existed fails to load with an error ending this DB was written by an older
pkgforge: run initdb and record again; there is no migration.
Sentinels
Two sentinel values let a command defer a field to the staged file:
-— leave at the OS default: do not set this field (e.g. don't chown).--— resolve from the file on disk: read the actual value from the staged file when the entry is recorded.
For example, install --mode=-- ... (or -m--, or -m auto) records whatever
mode the staged file already has, while install -m 644 ... records and
applies 644. The sentinel may also be detached (-m -- ...), and -m has
the auto alias (owner/group get none, since auto can be a real account
name).
-m is validated before anything is staged: it accepts 1-4 octal digits
(normalized, so 0644 is stored as 644), -, -- or auto. An explicit
empty value (-m "") also exits 2. Empty mode/owner/group fields already
in a DB (e.g. written by an older pkgforge) are read back as -.
Build root mapping
--buildroot is the staging directory that maps to / in the DB. Installing
app.conf to /etc under --buildroot build/root stages the file at
build/root/etc/app.conf and records it under the key /etc/app.conf. This
keeps the DB independent of where the staging happened, so a dump produces
absolute target paths a packager expects.
DESTINATION/PATH must resolve inside --buildroot: a .. that climbs above
it, or a symlinked path component leading outside it, is refused before
anything is written or recorded, instead of silently landing (or reading and
writing) outside the build root. An in-root .. (e.g.
/usr/share/../lib/x) is normalized, both on disk and in the recorded key.