feat(backup): open sqlite → pg .picpeak restore as the supported upgrade direction (#1041) (#1043)

Reshaped onto main after #1039 landed the coercion engine
(typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now
only the policy delta on top of it:

- validateManifest: replace the CLI-only allowEngineSwitch opt-in with a
  direction rule — sqlite → pg allowed (upload UI and CLI alike),
  pg → sqlite refused with a message naming the supported direction
- importFromPicpeak: derive crossEngine from the manifest's engine
  (absent field = target engine, the exact pre-change behavior), log it,
  return it; route passes it through
- scripts/migrate-sqlite-to-postgres.js: rely on the shared gate, drop
  the flag
- restore card: direction stated in the intro, cross-engine notice after
  a converting restore; both strings in en.json + de.json; removed the
  orphaned settings.backup.picpeak locale node (unreferenced, stale copy)
- picpeakCrossEngine.test.js: direction policy, epochToIso (ms, seconds,
  numeric strings), coerceForTargetEngine units, plus
  PICPEAK_PG_TEST_URL-gated real-Postgres stored-value assertions

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Luca
2026-08-16 19:01:36 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 18b1e0f66e
commit 8809564aad
7 changed files with 250 additions and 64 deletions
@@ -16,6 +16,7 @@ interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
crossEngine?: boolean;
sessionInvalidated?: boolean;
}
@@ -137,7 +138,7 @@ export const PicpeakRestoreCard: React.FC = () => {
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Restoring a SQLite backup onto a PostgreSQL instance is supported (the upgrade path); other engine combinations must match.')}
</p>
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
<Button
@@ -163,6 +164,11 @@ export const PicpeakRestoreCard: React.FC = () => {
files: result.filesRestored,
})}
</p>
{result.crossEngine && (
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
{t('backup.picpeak.crossEngineNote', 'Cross-engine restore: a SQLite backup was converted onto this PostgreSQL instance.')}
</p>
)}
{result.usesExternalMedia && (
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />