2b1c3588ae
* fix(external-media): store external paths from the media root (#1163) Stable twin of #1168. Stacked on the #1162 twin, which supplies deleteDuplicatePhotos. Importing a second folder into an event silently invalidated every photo already in it. external_relpath was stored relative to events.external_path, and every import overwrites that column, so the older rows were rebased onto the new folder. Nothing errored and the grid still rendered — thumbnails are written to local storage during the import while the base path is still correct — so only the things that need the original broke. The reporter had 7547 of 8004 rows pointing into the void. - external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is self-describing and nothing an admin does to the event can move it. - migration 177 folds each event's base into its rows. Where the current resolution is missing it walks up for an ancestor holding a file of the same name AND the size the import recorded — existence alone would let a deleted file adopt an unrelated namesake and serve the wrong original. Rows it cannot place keep resolving where they resolve today, and the probe is skipped entirely when the mount is unreachable. - probing is read-only and runs first; the rewrites and the marker commit together, so an interrupted fold cannot be folded twice. - rewrites are staged through a per-row parking value, because a final path can equal another row's current one; and migration 177 re-throws without the driver's error code, which run-migrations-safe would otherwise read as "schema already exists". - the fold also runs after a .picpeak restore, since knex_migrations is excluded from the archive, and a failure there is reported rather than presented as a clean restore. - drops the duplicate-leaf-segment guess in photoResolver, which papered over this same double-prefixing. Divergence from the main twin: no face-scan requeue reordering. Face recognition is main-only, so the hazard of queueing rows against unconverted paths does not exist on this branch — in picpeakImportService or in restoreService. Verified on this branch: 23 new tests pass, and the four suites carrying base-relative fixtures were updated. Full suite leaves the same 5 pre-existing failures as origin/stable, unchanged. * fix(external-media): the fold's staging value must be storable on Postgres (#1163) External review found this on this branch first; it was on both. The two-pass rewrite parks each row on a temporary value, and that value was written with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres rejects it outright — "invalid byte sequence for encoding UTF8: 0x00" — so migration 177 rolled back on exactly the installs that need the two-pass repair, and only on the engine most of them run. Restores hit the same wall and reported the conversion as failed. The prefix is ordinary text now. Adds a gated Postgres test alongside the existing picpeakRestorePg one, because a SQLite-only suite structurally cannot catch this class: restoring the NUL makes exactly the two-pass repair case fail with that error, and nothing else. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
122 lines
4.7 KiB
JavaScript
122 lines
4.7 KiB
JavaScript
/**
|
|
* PostgreSQL integration test for the external-path fold (#1163).
|
|
*
|
|
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
|
|
* points at a throwaway Postgres DB, e.g.
|
|
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
|
|
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
|
|
*
|
|
* This exists because of a defect SQLite could not have caught. The two-pass
|
|
* rewrite parks each row on a temporary value, and that value was first written
|
|
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
|
|
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
|
|
* 187 would have rolled back on exactly the installs needing the repair — and
|
|
* only on the engine most of them run.
|
|
*
|
|
* The staging value is therefore an engine-level contract, not an
|
|
* implementation detail, and it is pinned here on the engine that constrains it.
|
|
*/
|
|
|
|
const knex = require('knex');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
|
const maybe = PG_URL ? describe : describe.skip;
|
|
|
|
maybe('external relpath fold on Postgres', () => {
|
|
let pgDb; let mediaRoot; let fold;
|
|
|
|
const touch = async (rel, bytes) => {
|
|
const full = path.join(mediaRoot, rel);
|
|
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
|
await fs.promises.writeFile(full, Buffer.alloc(bytes));
|
|
return bytes;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
|
|
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
|
|
jest.resetModules();
|
|
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
|
|
|
|
pgDb = knex({ client: 'pg', connection: PG_URL });
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
if (pgDb) await pgDb.destroy();
|
|
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
|
|
delete process.env.EXTERNAL_MEDIA_ROOT;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
|
|
await pgDb.schema.createTable('events', (t) => {
|
|
t.increments('id');
|
|
t.text('external_path');
|
|
});
|
|
await pgDb.schema.createTable('photos', (t) => {
|
|
t.increments('id');
|
|
t.integer('event_id');
|
|
t.text('external_relpath');
|
|
t.bigInteger('size_bytes');
|
|
t.string('source_origin').defaultTo('managed');
|
|
});
|
|
await pgDb.schema.createTable('app_settings', (t) => {
|
|
t.increments('id');
|
|
t.string('setting_key');
|
|
t.text('setting_value');
|
|
t.string('setting_type');
|
|
t.string('updated_at');
|
|
});
|
|
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
|
await fs.promises.mkdir(mediaRoot, { recursive: true });
|
|
});
|
|
|
|
const relpaths = async () =>
|
|
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
|
|
|
|
it('completes the two-pass repair that a NUL staging value would abort', async () => {
|
|
// The exact shape that forces staging: `photo.jpg` repairs up to
|
|
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
|
|
// deeper. Every final value is distinct, but a final value equals another
|
|
// row's current one, so the rewrite has to park first.
|
|
const a = await touch('Trip/photo.jpg', 11);
|
|
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
|
|
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
|
|
await pgDb('photos').insert([
|
|
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
|
|
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
|
|
]);
|
|
|
|
await fold(pgDb);
|
|
|
|
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
|
|
});
|
|
|
|
it('leaves no staging value behind', async () => {
|
|
await touch('Trip/a.jpg', 8);
|
|
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
|
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
|
|
|
await fold(pgDb);
|
|
|
|
const rows = await relpaths();
|
|
expect(rows).toEqual(['Trip/a.jpg']);
|
|
expect(rows.some((r) => r.includes('staging'))).toBe(false);
|
|
});
|
|
|
|
it('folds and marks in one transaction', async () => {
|
|
await touch('Trip/a.jpg', 8);
|
|
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
|
|
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
|
|
|
|
await fold(pgDb);
|
|
// Second run is a no-op: the marker committed with the rewrites.
|
|
await fold(pgDb);
|
|
|
|
expect(await relpaths()).toEqual(['Trip/a.jpg']);
|
|
});
|
|
});
|