Project archives
Move a project between Lific instances with its content, linked files and history, then verify the private destination before switching writers.
project-archive copies one project between local databases. It creates a new
private project on the destination. It never merges projects, overwrites an
existing identifier, or deletes the source.
Use the web UI when you can sign in to both servers, or the local CLI when you have access to their database files. Markdown exports are for reading, not importing. For a whole-instance backup, see backup and restore.
Two interfaces exist: the CLI below, run on the machines that hold the database files, and a REST surface the web UI drives. There is no MCP archive interface. The local operator already has database access; export does not impersonate a web user. Import requires an explicit active human admin on the destination, even when instance authorization is disabled.
lific --db /srv/private/lific.db project-archive export LIF --out /srv/backups/LIF.tar.gz
# Transfer LIF.tar.gz securely to the destination machine.
lific --db /srv/community/lific.db project-archive import /srv/backups/LIF.tar.gz --user blakeThe destination database and admin must already exist. --config can select a
configuration instead of --db. The archive path must not already exist on
export. Both commands print a JSON report containing the project identifier,
row and blob counts, and unresolved external references. Errors exit nonzero.
The named destination admin becomes the project's lead. No source memberships or permission grants transfer. The new lead grant adds a destination audit entry: over the CLI with no authenticated actor, over the web attributed to the signed-in admin who ran the import. Imported history remains unchanged. A published source project imports as private. Review descriptions, comments and files before enabling publication separately. Historical text can contain secrets removed from today's content. Treat the archive as private data, not as a public download.
What transfers
Format 1 includes explicit columns from these tables:
projects,modules,labels,foldersissues,pages,plans,plan_steps,commentsissue_labels,page_labels,issue_relations,page_issue_linksattachments,attachment_links- project-content
audit_logentries andstatus_transitions
Deleted issues, pages and comments transfer with their tombstones. Readable issue/page/plan sequences, timestamps, ordering, status and issue source metadata are preserved. Internal database IDs are allocated on the destination; links between exported records are remapped, including nested folders and plan steps.
Relative /api/attachments/123 references in current descriptions, comments,
page content and history are rewritten to the new attachment IDs. Files transfer
only when an attachment link associates them with exported content. A reference
to an unexported attachment is reported and changed to an inactive
/unresolved-source-attachment/123 path so it cannot select an unrelated file on
the destination. Historical references to files no longer linked to the project
follow this rule too. Absolute attachment URLs retain their original address and
are reported for manual review. Other prose and URLs are retained as written;
this command does not crawl links or sanitize private text.
Cross-project database relations are reported and omitted. Optional external parent/anchor references are cleared. Neighboring projects are never pulled in.
Accounts, passwords, API keys, OAuth data, sessions, settings, memberships, grants, sidebar groups, saved views, repository bindings and source sync cursors do not transfer. Comment mention text remains, but account-linked mention notifications do not transfer. Search indexes are rebuilt from imported content; extractable text attachments are indexed from their verified bytes. Thumbnails can be regenerated by the destination.
Authorship and history
Imported comments and attachments have no destination author account. They carry
an inert name marked (imported). Matching a destination username does not grant
authorship or permissions. Existing comment/sync response shapes represent an
imported comment's missing account with user_id: -1; the stored user_id is
NULL. This sentinel is not a user record. Attachment uploader IDs and historical
actor IDs are NULL.
Imported history uses the imported transport. Its remapped entity IDs refer to
destination records; a purged historical entity uses ID 0, not a guessed match.
The imported_source column retains the original historical record as JSON with
source IDs. Those IDs are provenance, not links to destination accounts or
content. They are available in subsequent project archives or through local SQL,
not a separate history-inspection command. project_archive_provenance records
the export timestamp, source project ID, format version and unresolved-reference
report. Re-export preserves existing inert names and original history snapshots.
Archive validation and failure handling
The archive is a gzip-compressed tar containing manifest.json first, followed
only by regular blobs/<sha256> files. Format version 1 is independent of the
database migration version. Table names and positional column definitions are
compiled into Lific. Archive data cannot supply SQL, column names or schema.
Limits apply to both export and import, and depend on the interface. The CLI runs as the local operator, who already holds the database file. An HTTP caller runs on shared server resources and gets a much smaller budget.
| Limit | CLI | Web |
|---|---|---|
| Compressed archive | 2 GiB | 128 MiB |
| Decompressed archive | 2 GiB plus framing | 256 MiB |
| Manifest JSON | 64 MiB | 16 MiB |
| Total rows | 200,000 | 50,000 |
| Distinct blobs | 10,000 | 2,000 |
| One blob | 256 MiB | 64 MiB |
| Combined blob bytes | 2 GiB | 192 MiB |
| Folder or step nesting | 128 levels | 128 levels |
GET /api/project-archives returns the web numbers as max_upload_bytes,
max_expanded_bytes, max_metadata_bytes, max_blob_bytes,
max_blob_total_bytes, max_rows and max_blobs, so a client can refuse an
impossible archive before sending it. Use the CLI for archives above the web
limits, provided they still fit the CLI limits.
Over HTTP, exceeding any of these answers 413. A malformed, invalid or unsafe archive answers 400. The distinction is made from Lific's own record of which limit was hit, never from the text of an error, so an archive cannot choose its status code by writing limit-shaped strings into its data.
Generated reference reports are capped at the row limit in messages and at the manifest limit in text: 200,000 messages and 64 MiB on the CLI, 50,000 and 16 MiB over the web. Rewritten content is capped at the same text budget across all rows, including history. Every limit is checked during processing, before the report, the archive or the staged upload can grow past it. Nothing is measured after the fact.
Parsing also bounds row widths, rejects nested JSON values in rows, and caps decompressed tar framing overhead. Unknown versions/tables/fields, duplicate entries, invalid relationships, cycles, unsafe paths, symlinks, hard links, nonfiles, missing blobs, size mismatches and checksum failures stop the import. Hashes detect damaged bytes; they do not establish who created the archive.
Export holds a SQLite read transaction and the attachment-store lock throughout the snapshot. WAL checkpoints do not change the rows seen by that transaction. The output is installed without overwriting another file and uses mode 0600 on Unix. Temporary staging directories are private. On other platforms, restrict the archive directory with the operating system's access controls.
Import stages and verifies every blob before beginning its database transaction. Existing destination files are verified and never overwritten. New blobs are flushed before the new project is committed. Database rows, regenerated sync sequences and indexes become visible together. A failed import rolls back the project and attempts to remove newly installed unreferenced blobs. Cleanup can fail, and a process or machine crash can leave unreferenced files too. Neither exposes a partial project or removes an existing shared blob. Do not delete attachment files blindly after a failure. Keep the archive and retry after resolving the reported error.
After import, check the JSON reference report, open representative issues and pages, inspect deleted content and history, and download an attachment. Stop writers on the source before the final export, then switch their configurations only after verifying the destination. Until that cutover, the source remains the authoritative project.
The HTTP interface
Three routes, all under a single instance-wide slot: one archive export or import runs at a time, and a request that arrives while one is running is refused with 429 before its body is read.
| Route | Who |
|---|---|
GET /api/project-archives | Any signed-in person. Returns the limits above and can_import. |
GET /api/project-archives/{identifier} | The project's lead, or an instance admin. |
POST /api/project-archives | An instance admin, whether or not authorization enforcement is on. |
Only a browser session reaches these routes. An API key, an unbound operator key, an OAuth connector token, a bot, and the first-admin identity an authentication-disabled instance hands to a credential-less request are all refused, because an archive is the project's entire history and the only credential allowed to ask for it is a human who signed in. The session is re-read from the database at every decision point, including inside the read transaction that takes the export snapshot and inside the write transaction that creates the imported project, so a sign-out or a demotion committed in between stops the request rather than being noticed afterwards.
The upload is one multipart/form-data request with exactly one field named
archive. Its filename and content type are ignored. Any additional field is
refused. The bytes are counted as they stream to a private temporary file and
the upload is cut off the moment it passes the limit. An upload has 120 seconds
overall and 20 seconds between reads.
GET /api/project-archives/{identifier} resolves the identifier to a row ID
once and exports that ID. An identifier is a mutable label, so re-resolving it
inside the snapshot could hand back a project the caller was never authorized
for; the download is named after the identifier the snapshot itself saw.
The response is application/gzip as <IDENTIFIER>.lific.tar.gz with
Cache-Control: no-store. Authorization is rechecked after the archive is
generated and before the first byte is sent, so a download that queued behind
other work cannot outlive the permission that started it.
A successful import answers 201 with the new project and a report: the
identifier, per-table row counts, the blob count, up to the first 100
unresolved external references, and external_reference_count for the total.
The full list is stored in project_archive_provenance and comes back out in
the next archive of that project. The CLI's own JSON report is unchanged.
An import whose connection is lost has an unknown result. The server does not abandon a running import when the client disconnects, and it does not report a failure for work that committed. A client must not retry automatically. Check the project list first: if the project is there, the import may have succeeded. Open it and verify its contents and import report before switching writers; a pre-existing project with that identifier is not proof that this request committed.
Use project archives in the web UI
Sign in as the project lead or an instance admin on the source. In the project
overview, find Project archive, read the history warning, confirm it, and
choose Download project archive. This downloads <ID>.lific.tar.gz and leaves
the source unchanged. The toolbar's existing Markdown export is a separate format,
not an archive you can import.
On the destination, sign in as an instance admin. Open New project, then
Import a project archive. This also works before the instance has any projects.
Choose the .tar.gz file, check its filename and size, read the confirmation, and
choose Import as private project. The page shows the server's current upload
and expanded-size limits. Use the local CLI for archives above the web limits.
Upload progress measures bytes sent. Importing... means the server is processing the archive; it is not a percentage estimate and cannot be canceled. Returning to the import page in the same tab keeps the current operation and its result. If the connection or page is lost, the import may still complete. Check the project list before uploading again. The UI never retries an import for you.
The result stays on screen with record and file counts and unresolved references (up to the first 100, with the total shown). Review these before choosing Open imported project. Imported author names are inert text, not accounts. The destination is private and you become its lead. A duplicate identifier fails without merging or overwriting anything. Publishing remains a separate action; review the content and linked files before enabling it.
See public projects for the publication checklist. If the destination will serve the same repository, set up its repository binding separately; bindings do not travel in the archive.