Lific

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 blake

The 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, folders
  • issues, pages, plans, plan_steps, comments
  • issue_labels, page_labels, issue_relations, page_issue_links
  • attachments, attachment_links
  • project-content audit_log entries and status_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.

LimitCLIWeb
Compressed archive2 GiB128 MiB
Decompressed archive2 GiB plus framing256 MiB
Manifest JSON64 MiB16 MiB
Total rows200,00050,000
Distinct blobs10,0002,000
One blob256 MiB64 MiB
Combined blob bytes2 GiB192 MiB
Folder or step nesting128 levels128 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.

RouteWho
GET /api/project-archivesAny 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-archivesAn 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.

On this page