980378a17b
Stable backport of #1043 (main: 8809564a).
sqlite → pg restore is allowed for anyone holding backup.restore, from the
upload UI and the CLI alike, gated by the manifest-direction rule in
validateManifest. pg → sqlite stays refused, with an error naming the
supported direction. allowEngineSwitch is removed rather than kept alongside:
one gate, no way to drive the refused direction.
Two resolutions were needed against stable rather than a clean cherry-pick,
both from known main/stable divergences:
- replaceAllTables has no roleSnapshot parameter on this branch, so the call
keeps stable's 4-arg signature while taking the derived { crossEngine }.
- resyncSequences was guarded by `if (allowEngineSwitch)`, which this change
removes — leaving an undefined reference. It now runs unconditionally,
matching main. That also closes a stable-only gap: a same-engine pg → pg
restore previously left identity sequences stale, so the next natural
insert collided on the primary key.
Also exports resyncSequences (the function already existed here, main already
exports it) so the cross-engine suite can drive the post-restore fixup.
Verified on this branch: all four picpeak suites green on SQLite, and 20/20
against a real Postgres 15 with the PICPEAK_PG_TEST_URL-gated cases executing.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
198 lines
7.9 KiB
JavaScript
198 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
|
|
* a PostgreSQL instance — the official small-install → full-stack upgrade
|
|
* path — now allowed by validateManifest's direction rule instead of the
|
|
* former CLI-only allowEngineSwitch flag. The coercion engine itself
|
|
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
|
|
* these tests pin the direction policy and the coercion's cross-engine
|
|
* value-correctness.
|
|
*
|
|
* Ungated: validateManifest direction rules and the pure coercion units.
|
|
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
|
|
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
|
|
*
|
|
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
|
|
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
|
|
* not just row counts, e.g.
|
|
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
|
|
* npx jest __tests__/integration/picpeakCrossEngine.test.js
|
|
*/
|
|
const knexLib = require('knex');
|
|
|
|
describe('validateManifest cross-engine direction (pg target)', () => {
|
|
let validateManifest;
|
|
|
|
beforeAll(() => {
|
|
jest.resetModules();
|
|
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
|
// validateManifest wraps its knex_migrations lookup in try/catch — a
|
|
// throwing stub simply skips the forward-only check, which is not under
|
|
// test here.
|
|
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
|
|
({ validateManifest } = require('../../src/services/picpeakImportService'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.dontMock('../../src/database/db');
|
|
jest.dontMock('../../knexfile');
|
|
jest.resetModules();
|
|
});
|
|
|
|
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
|
|
const blockers = await validateManifest({
|
|
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
|
|
});
|
|
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
|
});
|
|
|
|
it('still allows same-engine pg → pg', async () => {
|
|
const blockers = await validateManifest({
|
|
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
|
});
|
|
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('epochToIso (landed with #1039)', () => {
|
|
let epochToIso;
|
|
|
|
beforeAll(() => {
|
|
jest.resetModules();
|
|
({ epochToIso } = require('../../src/services/picpeakImportService'));
|
|
});
|
|
|
|
it('converts epoch milliseconds', () => {
|
|
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
|
|
});
|
|
|
|
it('converts epoch SECONDS to the same instant, not January 1970', () => {
|
|
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
|
|
});
|
|
|
|
it('converts numeric strings', () => {
|
|
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
|
|
});
|
|
|
|
it('passes non-numeric values through untouched', () => {
|
|
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
|
|
});
|
|
});
|
|
|
|
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
|
|
let coerceForTargetEngine;
|
|
|
|
beforeAll(() => {
|
|
jest.resetModules();
|
|
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
|
|
});
|
|
|
|
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
|
|
|
|
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
|
|
const [row] = coerceForTargetEngine(
|
|
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
|
|
types
|
|
);
|
|
expect(row.is_active).toBe(true);
|
|
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
|
|
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
|
|
});
|
|
|
|
it('coerces falsy variants and passes null/empty through', () => {
|
|
const [row] = coerceForTargetEngine(
|
|
[{ is_active: 0, created_at: null, expires_at: '' }],
|
|
types
|
|
);
|
|
expect(row.is_active).toBe(false);
|
|
expect(row.created_at).toBeNull();
|
|
expect(row.expires_at).toBe('');
|
|
});
|
|
});
|
|
|
|
// ── Real-Postgres integration (gated) ────────────────────────────────────────
|
|
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
|
const maybe = PG_URL ? describe : describe.skip;
|
|
|
|
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
|
|
let pgDb;
|
|
let svc;
|
|
|
|
beforeAll(async () => {
|
|
pgDb = knexLib({ client: 'pg', connection: PG_URL });
|
|
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
|
await pgDb.schema.createTable('xengine_events', (t) => {
|
|
t.increments('id');
|
|
t.string('slug');
|
|
t.boolean('is_active').defaultTo(true);
|
|
t.boolean('allow_downloads').defaultTo(true);
|
|
t.timestamp('created_at');
|
|
t.timestamp('expires_at');
|
|
});
|
|
await pgDb.schema.createTable('xengine_settings', (t) => {
|
|
t.increments('id');
|
|
t.string('setting_key').notNullable().unique();
|
|
t.jsonb('setting_value');
|
|
});
|
|
|
|
jest.resetModules();
|
|
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
|
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
|
|
svc = require('../../src/services/picpeakImportService');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
jest.dontMock('../../src/database/db');
|
|
jest.dontMock('../../knexfile');
|
|
if (pgDb) {
|
|
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
|
|
await pgDb.destroy();
|
|
}
|
|
});
|
|
|
|
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
|
|
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
|
|
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
|
|
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
|
|
});
|
|
|
|
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
|
|
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
|
|
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
|
|
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
|
|
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
|
|
// the text is already what pg wants).
|
|
const epoch = 1723400000000;
|
|
const eventRows = [
|
|
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
|
|
];
|
|
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
|
|
|
|
await pgDb.transaction(async (trx) => {
|
|
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
|
|
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
|
|
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
|
|
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
|
|
});
|
|
|
|
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
|
|
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
|
|
expect(ev.allow_downloads).toBe(false); // 0 → false
|
|
expect(new Date(ev.created_at).getTime()).toBe(epoch);
|
|
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
|
|
|
|
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
|
|
// jsonb parsed back by the driver — value intact, no double encoding.
|
|
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
|
|
});
|
|
|
|
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
|
|
await svc.resyncSequences(['xengine_events']);
|
|
const [next] = await pgDb('xengine_events')
|
|
.insert({ slug: 'fresh', is_active: true })
|
|
.returning('id');
|
|
expect(Number(next.id || next)).toBe(2);
|
|
});
|
|
});
|