fix(demo): save the first baseline only after a real seed again

Moving the first baseline save out of the boot-time seed into DemoResetJob
also changed when it happened. The seed saved it only on the boot that put
the example trips in; a later boot returned early and saved nothing, so an
instance with data but no baseline logged "No baseline found, skipping" on
every hourly reset. The job instead saved one on any demo boot that found
no travel-baseline.db, freezing whatever the database held at that moment,
user edits included, as the target the hourly reset rolls back to.

The seed now leaves a mark when it seeds the example trips, and the job
saves the first baseline only when that mark is set and no baseline
exists, which is the old trigger. As before, the save does not depend on
the cron registrar being enabled. The demo mode wiki page says so.
This commit is contained in:
Maurice
2026-10-08 15:36:53 +02:00
parent 0aa5b36661
commit fd28ba7fd3
6 changed files with 74 additions and 13 deletions
+23 -1
View File
@@ -153,4 +153,26 @@ function hasBaseline(): boolean {
return fs.existsSync(baselinePath);
}
export { resetDemoUser, saveBaseline, hasBaseline };
/**
* Whether the boot-time seed put the example trips in during this process.
*
* The first baseline is saved only after a real seed, as it always was: a demo
* database that already holds data but has no baseline keeps it that way, and
* the hourly reset stays a logged no-op until the admin saves one. The seed
* runs before the container exists, so it leaves this mark and DemoResetJob,
* which has the backup port, saves the baseline once the app is up.
*/
let seededExampleTrips = false;
function markExampleTripsSeeded(): void {
seededExampleTrips = true;
}
/** Reads the mark and clears it, so one seed saves at most one baseline. */
function takeExampleTripsSeeded(): boolean {
const seeded = seededExampleTrips;
seededExampleTrips = false;
return seeded;
}
export { resetDemoUser, saveBaseline, hasBaseline, markExampleTripsSeeded, takeExampleTripsSeeded };
+5 -3
View File
@@ -4,7 +4,7 @@ import { DEMO_PASS } from '../nest/common/demo';
import { DemoRepository, type NewDemoPlaceRow } from '../db/repositories/DemoRepository';
// Static like in demo-reset.job.ts: the module top is inert, everything that
// touches the database happens inside the functions.
import { requireEntityManager } from './demo-reset';
import { markExampleTripsSeeded, requireEntityManager } from './demo-reset';
// D6 (task-2-review.md's controller ruling / non-HTTP caller table): this runs
// from `runSchemaBootstrap` before `app.init()`, outside any request context, but
@@ -63,8 +63,10 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> {
console.log('[Demo] Seeding example trips...');
await seedExampleTrips(demo, adminId, demoId);
// The first baseline is saved once the app is up, by DemoResetJob, through
// the injected backup port this boot-time seed has no access to.
// The first baseline follows this seed and only this seed. DemoResetJob saves
// it once the app is up, through the injected backup port this boot-time
// seed has no access to.
markExampleTripsSeeded();
return { adminId, demoId };
}
+8 -5
View File
@@ -5,7 +5,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { DATABASE_BACKUP, type DatabaseBackupStrategy } from '../database/database-backup.interface';
import { withRequestContext } from '../database/request-context';
import { hasBaseline, resetDemoUser, saveBaseline } from '../../demo/demo-reset';
import { hasBaseline, resetDemoUser, saveBaseline, takeExampleTripsSeeded } from '../../demo/demo-reset';
/**
* Demo mode: hourly reset of demo user data (moved from src/scheduler.ts).
@@ -15,9 +15,12 @@ import { hasBaseline, resetDemoUser, saveBaseline } from '../../demo/demo-reset'
* side-effect-free, and the close/swap/reopen sequence runs inside
* resetDemoUser at tick time, through the injected backup port.
*
* It also saves the first baseline, on a demo boot that has none. That used to
* happen inside the boot-time demo seed, which runs before the container can
* hand anything in; here the port is injected like everywhere else.
* It also saves the first baseline, on the boot whose seed has just put the
* example trips in and only when no baseline exists yet: the same trigger the
* save had inside the boot-time demo seed, which runs before the container can
* hand the port in. A demo database that already holds data without a baseline
* is left alone, so its hourly reset stays a logged no-op as before. Like the
* old save, it does not depend on the cron registrar being enabled.
*/
@Injectable()
export class DemoResetJob implements OnApplicationBootstrap {
@@ -36,7 +39,7 @@ export class DemoResetJob implements OnApplicationBootstrap {
this.registrar.register('demo-reset', '0 * * * *', () => void this.tick(), { timezone: 'none' });
logInfo('Demo hourly reset scheduled');
}
if (!hasBaseline()) await this.saveFirstBaseline();
if (takeExampleTripsSeeded() && !hasBaseline()) await this.saveFirstBaseline();
}
async tick(): Promise<void> {
+16
View File
@@ -30,6 +30,7 @@ vi.mock('../../../src/demo/demo-reset', async (importOriginal) => {
});
import { seedDemoData } from '../../../src/demo/demo-seed';
import { takeExampleTripsSeeded } from '../../../src/demo/demo-reset';
describe('demo seeding', () => {
let db: Database.Database;
@@ -118,4 +119,19 @@ describe('demo seeding', () => {
const reservationTimes = (db.prepare('SELECT reservation_time FROM reservations ORDER BY id').all() as { reservation_time: string }[]).map((r) => r.reservation_time);
for (const t of reservationTimes) expect(t).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
});
it('DEMOSEED-005: marks a first baseline as due only on the boot that seeded the example trips', async () => {
// The mark is process state; earlier cases may have left it set.
takeExampleTripsSeeded();
await seed();
expect(takeExampleTripsSeeded()).toBe(true);
// Taking it clears it, so one seed saves at most one baseline.
expect(takeExampleTripsSeeded()).toBe(false);
// A later boot finds the trips in place and returns before seeding: no mark,
// so a database that already holds data never becomes a baseline on its own.
await seed();
expect(takeExampleTripsSeeded()).toBe(false);
});
});
+21 -3
View File
@@ -7,14 +7,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
const { resetDemoUserMock, saveBaselineMock, hasBaselineMock } = vi.hoisted(() => ({
const { resetDemoUserMock, saveBaselineMock, hasBaselineMock, takeExampleTripsSeededMock } = vi.hoisted(() => ({
resetDemoUserMock: vi.fn(),
saveBaselineMock: vi.fn(async () => {}),
hasBaselineMock: vi.fn(() => true),
takeExampleTripsSeededMock: vi.fn(() => false),
}));
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
vi.mock('../../../src/demo/demo-reset', () => ({ resetDemoUser: resetDemoUserMock, saveBaseline: saveBaselineMock, hasBaseline: hasBaselineMock }));
vi.mock('../../../src/demo/demo-reset', () => ({
resetDemoUser: resetDemoUserMock,
saveBaseline: saveBaselineMock,
hasBaseline: hasBaselineMock,
takeExampleTripsSeeded: takeExampleTripsSeededMock,
}));
import { VersionCheckJob } from '../../../src/nest/admin/version-check.job';
import { DemoResetJob } from '../../../src/nest/admin/demo-reset.job';
@@ -35,6 +41,7 @@ function registrarStub(enabled = true) {
beforeEach(() => {
vi.clearAllMocks();
hasBaselineMock.mockReturnValue(true);
takeExampleTripsSeededMock.mockReturnValue(false);
});
describe('VersionCheckJob', () => {
@@ -105,7 +112,8 @@ describe('DemoResetJob', () => {
expect(logMock.logError).toHaveBeenCalledWith('Demo reset: baseline gone');
});
it('AJOB-007: a demo boot without a baseline saves the first one through the port, gate or not', async () => {
it('AJOB-007: the boot that seeded the example trips saves the first baseline through the port, gate or not', async () => {
takeExampleTripsSeededMock.mockReturnValue(true);
hasBaselineMock.mockReturnValue(false);
const { job } = make(true, false);
await job.onApplicationBootstrap();
@@ -113,6 +121,7 @@ describe('DemoResetJob', () => {
});
it('AJOB-008: an existing baseline is left alone, and so is a non-demo boot', async () => {
takeExampleTripsSeededMock.mockReturnValue(true);
await make(true).job.onApplicationBootstrap();
hasBaselineMock.mockReturnValue(false);
await make(false).job.onApplicationBootstrap();
@@ -120,9 +129,18 @@ describe('DemoResetJob', () => {
});
it('AJOB-009: a first baseline that cannot be saved is logged, not thrown into the boot', async () => {
takeExampleTripsSeededMock.mockReturnValue(true);
hasBaselineMock.mockReturnValue(false);
saveBaselineMock.mockRejectedValueOnce(new Error('database or disk is full'));
await expect(make(true).job.onApplicationBootstrap()).resolves.toBeUndefined();
expect(logMock.logError).toHaveBeenCalledWith('Demo baseline: database or disk is full');
});
it('AJOB-010: a demo boot that seeded nothing saves no baseline, even with none on disk', async () => {
// A database that already holds data (possibly user edits) and has no
// baseline must not become the reset target behind the admin's back.
hasBaselineMock.mockReturnValue(false);
await make(true).job.onApplicationBootstrap();
expect(saveBaselineMock).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ The baseline is the snapshot the hourly reset restores to. The admin can update
In the admin panel, a **Demo Baseline** card sits above the tabs while demo mode is on; its **Save Baseline** button calls this endpoint. The baseline captures the current state of the database, trip data, settings and encrypted API keys included, so demo features (maps, photos, weather) continue to work after each reset. It is written as a consistent snapshot and only then put in place of the previous baseline, so a save that fails leaves the previous one intact.
On first start with demo mode active, TREK seeds three example trips (Tokyo & Kyoto, Barcelona Long Weekend, New York City) owned by the admin and shared with the demo user. Once the server is up, a demo start that finds no baseline saves the initial one automatically.
On first start with demo mode active, TREK seeds three example trips (Tokyo & Kyoto, Barcelona Long Weekend, New York City) owned by the admin and shared with the demo user, then saves the initial baseline automatically once the server is up. Only that seeding start saves a baseline on its own: an instance that already holds data but has no baseline keeps skipping the hourly reset until the admin saves one.
## Limitations