From d33ce68b51f31159f3a6cccfc82f79c5ff33daaa Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sun, 11 Oct 2026 15:10:55 +0200 Subject: [PATCH] fix(server): an app on tables of its own starts its sample afresh --- .../sample-starts-afresh-on-own-tables.md | 5 + apps/server/src/apps/install-service.ts | 5 +- apps/server/src/apps/sample-data.ts | 4 + apps/server/test/app-sample-reinstall.test.ts | 159 +++++++++++++++++- 4 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 .changeset/sample-starts-afresh-on-own-tables.md diff --git a/.changeset/sample-starts-afresh-on-own-tables.md b/.changeset/sample-starts-afresh-on-own-tables.md new file mode 100644 index 000000000..c0b8b22f7 --- /dev/null +++ b/.changeset/sample-starts-afresh-on-own-tables.md @@ -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. diff --git a/apps/server/src/apps/install-service.ts b/apps/server/src/apps/install-service.ts index bd19d9e58..d11276d33 100644 --- a/apps/server/src/apps/install-service.ts +++ b/apps/server/src/apps/install-service.ts @@ -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 }); } } diff --git a/apps/server/src/apps/sample-data.ts b/apps/server/src/apps/sample-data.ts index 55cd9b6f1..69e337c45 100644 --- a/apps/server/src/apps/sample-data.ts +++ b/apps/server/src/apps/sample-data.ts @@ -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() @@ -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, diff --git a/apps/server/test/app-sample-reinstall.test.ts b/apps/server/test/app-sample-reinstall.test.ts index f102fb5c4..31500f6f7 100644 --- a/apps/server/test/app-sample-reinstall.test.ts +++ b/apps/server/test/app-sample-reinstall.test.ts @@ -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 () => { @@ -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; + 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, + ); +});