Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sample-starts-afresh-on-own-tables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adminium/server': patch
---

An app installed again on tables of its own (its earlier tables were dropped, or were an older version's under plain names) starts its sample afresh: the earlier install's list of sample rows is no longer counted with the new one, which doubled the count shown. Rows an earlier version left behind stay as ordinary rows.
5 changes: 4 additions & 1 deletion apps/server/src/apps/install-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2514,7 +2514,10 @@ export function createAppInstallService(deps: AppRoutesDeps) {
// sample is then still the sample — it can be removed, and is not offered a second time over itself.
if (connectionId !== undefined) {
const keptList = (await tableRecords.forInstall(connectionId, key)).find((record) => record.role === 'sample-ledger' && record.state === 'released');
if (keptList !== undefined && ((await deps.schemaTarget?.read(connectionId, new Set([keptList.tableName])))?.tables ?? []).some((table) => table.ref === keptList.tableName)) {
if (keptList !== undefined && tablesPlan !== undefined && tablesPlan.plan.reuse.length === 0) {
// An install that starts on tables of its own reads none of the rows listed: that list is no longer its.
await tableRecords.setState(keptList.id, 'dropped');
} else if (keptList !== undefined && ((await deps.schemaTarget?.read(connectionId, new Set([keptList.tableName])))?.tables ?? []).some((table) => table.ref === keptList.tableName)) {
await tableRecords.record({ appKey: key, manifestId: rowId, connectionId, ref: keptList.ref, tableName: keptList.tableName, owned: keptList.owned, state: 'created', role: 'sample-ledger', prefix: keptList.prefix });
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/apps/sample-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,8 @@ export function createSampleDataService(deps: SampleDataDeps) {
const existing = await ledgerRecord(app, connectionId);
if (existing !== undefined) return existing.tableName;
const name = ledgerNameFor(app.manifest);
// A list an earlier install left, of rows in tables this install does not read (it started on its own).
const stale = (await records.forInstall(connectionId, app.key)).some((record) => record.role === 'sample-ledger' && record.state === 'dropped' && record.tableName === name);
await asDb(handle.db)
.schema.createTable(name)
.ifNotExists()
Expand All @@ -978,6 +980,8 @@ export function createSampleDataService(deps: SampleDataDeps) {
.addColumn('col_hashes', 'text', (col) => col.notNull())
.addColumn('created_at', 'bigint', (col) => col.notNull())
.execute();
// Its table, when still there, is taken empty: counted with this install's, those rows would read as its sample.
if (stale) await asDb(handle.db).deleteFrom(name as never).execute();
await records.record({
appKey: app.key,
manifestId: app.manifestId,
Expand Down
159 changes: 154 additions & 5 deletions apps/server/test/app-sample-reinstall.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-only
/**
* AN APP REMOVED WITH ITS TABLES KEPT, THEN INSTALLED AGAIN.
* AN APP REMOVED, THEN INSTALLED AGAIN: WHAT BECOMES OF ITS SAMPLE.
*
* The tables are found and taken as they are. The list of the sample's rows
* is one of the things kept, and is taken back with them: the sample is still
* the sample afterwards — it is not offered a second time over itself, and it
* can still be removed.
* Over the tables it kept: the tables are found and taken as they are. The
* list of the sample's rows is one of the things kept, and is taken back with
* them: the sample is still the sample afterwards — it is not offered a second
* time over itself, and it can still be removed.
*
* On tables of its own (the earlier ones were dropped with it, or were an
* older version's under plain names): the earlier list is of rows this
* install does not read. It is not counted with the new sample, and the rows
* an earlier version left are the operator's, untouched.
*/
import { readFileSync } from 'node:fs';

import { appTablesRepo } from '@adminium/meta';
import { afterEach, describe, expect, it } from 'vitest';

import { createSampleDataService, findSampleApp } from '../src/apps/sample-data.js';
import { addOnHarness, type Harness } from './app-add-ons.helpers.js';
import { ENGINES, installHarness, type Harness as InstallHarness } from './app-install-harness.js';
import { LEGS } from './invoicing-install.helpers.js';
import { pointOfSale } from '../../../packages/manifest/test/menu-sharing-fixture.js';

let h: Harness | null = null;
afterEach(async () => {
Expand Down Expand Up @@ -69,3 +79,142 @@ describe.each(LEGS)('an app installed again over the tables it kept — %s', (di
expect(await orders()).toBe(1);
});
});

// ── A released app (Point of Sale), with its own sample, on every engine this run can reach ──────────────────────────

const POS_FILES = {
'seeds/pos.sample.json': readFileSync(new URL('../../../packages/manifest/test/fixtures/released/point-of-sale-0.2.2.sample.json', import.meta.url), 'utf8'),
};
const SLOW = 240_000;
const who = { locale: 'en-US', userId: null, userLabel: 'test' } as const;

let open: InstallHarness | null = null;
afterEach(async () => {
await open?.close();
open = null;
});

describe.each(ENGINES)('an app installed again over the tables it left — %s', (dialect, available) => {
it.skipIf(!available)(
'still knows its sample data: counted as loaded, and removable',
async () => {
const pos = (open = await installHarness(dialect, { superAdmin: true, full: true }));
const samples = pos.samples!;
const ledgerOf = async () => (await appTablesRepo(pos.meta).forInstall(pos.connectionId, 'pos')).find((record) => record.role === 'sample-ledger');
const dishes = async () => (await pos.rows(`SELECT id FROM pos_menu_items`)).length;

const first = await pos.install(pointOfSale(), {}, POS_FILES);
expect(first.statusCode, first.body).toBe(200);
await samples.add((await findSampleApp(pos.meta, 'pos'))!, who);
const before = await samples.status((await findSampleApp(pos.meta, 'pos'))!);
expect(before.loaded).toBe(true);
expect(before.total).toBeGreaterThan(0);
const sampleDishes = await dishes();
expect(sampleDishes).toBeGreaterThan(0);

// Removed, its tables kept: the list of its sample rows is kept with them.
const removed = await pos.inject({ method: 'DELETE', url: '/apps/pos' });
expect(removed.statusCode, removed.body).toBe(200);
expect((await ledgerOf())?.state).toBe('released');
expect(await dishes()).toBe(sampleDishes);

const again = await pos.install(pointOfSale(), {}, POS_FILES);
expect(again.statusCode, again.body).toBe(200);
const app = (await findSampleApp(pos.meta, 'pos'))!;
expect(await ledgerOf()).toMatchObject({ state: 'created', manifestId: app.manifestId });

// The same sample, by the same count, table for table.
const after = await samples.status(app);
expect({ loaded: after.loaded, total: after.total }).toEqual({ loaded: true, total: before.total });
expect([...after.tables].sort((a, b) => a.ref.localeCompare(b.ref))).toEqual([...before.tables].sort((a, b) => a.ref.localeCompare(b.ref)));

// And it can still be removed, every row of it.
const gone = await samples.remove(app, { keepChanged: false, userId: null, userLabel: 'test' });
expect(gone.removed).toBe(before.total);
expect(await dishes()).toBe(0);
expect((await samples.status(app)).loaded).toBe(false);
},
SLOW,
);

it.skipIf(!available)(
'starts its sample afresh when the new version starts on tables of its own',
async () => {
const pos = (open = await installHarness(dialect, { superAdmin: true, full: true }));
const samples = pos.samples!;
const ledgerOf = async () => (await appTablesRepo(pos.meta).forInstall(pos.connectionId, 'pos')).find((record) => record.role === 'sample-ledger');
// The version an older install came from: plain names.
const plain = pointOfSale();
const { prefixed: _prefixed, ...unprefixed } = plain['requiredSchema'] as Record<string, unknown>;
plain['requiredSchema'] = unprefixed;

const first = await pos.install(plain, {}, POS_FILES);
expect(first.statusCode, first.body).toBe(200);
await samples.add((await findSampleApp(pos.meta, 'pos'))!, who);
const before = await samples.status((await findSampleApp(pos.meta, 'pos'))!);
const plainDishes = (await pos.rows(`SELECT id FROM menu_items`)).length;
expect(plainDishes).toBeGreaterThan(0);
const removed = await pos.inject({ method: 'DELETE', url: '/apps/pos' });
expect(removed.statusCode, removed.body).toBe(200);

// The prefixed version makes its own tables, empty: the kept list is of rows it does not read.
const again = await pos.install(pointOfSale(), {}, POS_FILES);
expect(again.statusCode, again.body).toBe(200);
const app = (await findSampleApp(pos.meta, 'pos'))!;
expect((await pos.rows(`SELECT id FROM pos_menu_items`)).length).toBe(0);
expect((await ledgerOf())?.state).toBe('dropped');
expect(await samples.status(app)).toMatchObject({ offered: true, loaded: false, total: 0 });

// Its own sample is counted once: the earlier version's list is not added to it.
await samples.add(app, who);
const after = await samples.status(app);
expect({ loaded: after.loaded, total: after.total }).toEqual({ loaded: true, total: before.total });
expect((await pos.rows(`SELECT id FROM pos_menu_items`)).length).toBe(plainDishes);
const gone = await samples.remove(app, { keepChanged: false, userId: null, userLabel: 'test' });
expect(gone.removed).toBe(before.total);
expect((await pos.rows(`SELECT id FROM pos_menu_items`)).length).toBe(0);
// The rows the earlier version left are the operator's now, untouched.
expect((await pos.rows(`SELECT id FROM menu_items`)).length).toBe(plainDishes);
},
SLOW,
);

it.skipIf(!available)(
'starts its sample afresh after its tables were dropped with it',
async () => {
const pos = (open = await installHarness(dialect, { superAdmin: true, full: true }));
const samples = pos.samples!;
const ledgerOf = async () => (await appTablesRepo(pos.meta).forInstall(pos.connectionId, 'pos')).find((record) => record.role === 'sample-ledger');
const dishes = async () => (await pos.rows(`SELECT id FROM pos_menu_items`)).length;

const first = await pos.install(pointOfSale(), {}, POS_FILES);
expect(first.statusCode, first.body).toBe(200);
await samples.add((await findSampleApp(pos.meta, 'pos'))!, who);
const before = await samples.status((await findSampleApp(pos.meta, 'pos'))!);
const sampleDishes = await dishes();
expect(sampleDishes).toBeGreaterThan(0);

const removed = await pos.inject({ method: 'DELETE', url: '/apps/pos', payload: { dropTables: true, confirmKey: 'pos' } });
expect(removed.statusCode, removed.body).toBe(200);
// The list went with the tables it was of.
expect((await ledgerOf())?.state).toBe('dropped');
await expect(pos.rows(`SELECT seq FROM pos_sample_data`)).rejects.toThrow();

const again = await pos.install(pointOfSale(), {}, POS_FILES);
expect(again.statusCode, again.body).toBe(200);
const app = (await findSampleApp(pos.meta, 'pos'))!;
expect(await dishes()).toBe(0);
expect(await samples.status(app)).toMatchObject({ offered: true, loaded: false, total: 0 });

// Added again, it is the sample it was: the same count, and all of it removable.
await samples.add(app, who);
const after = await samples.status(app);
expect({ loaded: after.loaded, total: after.total }).toEqual({ loaded: true, total: before.total });
expect(await dishes()).toBe(sampleDishes);
const gone = await samples.remove(app, { keepChanged: false, userId: null, userLabel: 'test' });
expect(gone.removed).toBe(before.total);
expect(await dishes()).toBe(0);
},
SLOW,
);
});
Loading