openvibers/openvibe.host Commits

Host: the catalog serves (and is ready) before the offers migration applies; it waits behind the 0008 contract until 10-19 (#90)

OP OpenVibers committed 30 days ago · 01b697f900 · 3 files, +22 −2

* Host: the catalog serves (and is ready) before the offers migration applies; it waits behind the 0008 contract until 10-19

Host#89's deploy was rolled back.

**Cause.** Its migration `0009_catalog_offers` waits behind `0008_drop_hosting_engine`, a contract migration that sdk/db holds for 7 days after its expand: until 2026-10-19 00:37 UTC. A held contract also holds every migration after it, so `catalog_offers` does not exist yet. The readiness check required it, so the new release never became ready.

**Fix:**
- Readiness no longer lists `catalog_offers`. It goes back once 0009 has applied.
- The offers list answers empty while the table is missing (PostgreSQL `42P01`). The front page, `/catalog/offers` and the API serve normally, with no offers.

The two Sources feeds stay disabled until the table exists, so no item is held and lost.

`test/catalog-offers.test.js` adds a check with the table renamed away: the pages serve, the API is empty and `/api/ready` answers 200. The public catalog, ingest and offers tests pass.

* Host: the missing-table check injects the error (CI's PostgreSQL role cannot rename tables)

Changes

ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 6fa2cba..259f46e 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -177,7 +177,14 @@ function createCatalog({ store, community = null, log = console }) {
}
const offset = query.cursor == null ? 0 : Number(query.cursor);
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
-- const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
++ let rows;
++ try {
++ rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
++ } catch (err) {
++ // Before migration 0009 has applied (a held contract migration holds it), there are no offers yet.
++ if (err && err.code === '42P01') return { offers: [], next_cursor: null };
++ throw err;
++ }
return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
}
const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });
ddiff --git a/server/db.js b/server/db.js
iindex e9a99d5..56dc10b 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -9,7 +9,9 @@ const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
--const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_offers', 'catalog_ingest_cursor'];
++// catalog_offers (0009) is not required yet: the 0008 contract holds every later migration until 2026-10-19 00:37 UTC
++// (sdk/db keeps the order), and the offers page shows none until then. Required again once 0009 has applied.
++const CHARTER_TABLES = ['host_catalog_discussion_refs', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
ddiff --git a/test/catalog-offers.test.js b/test/catalog-offers.test.js
iindex 47b78f4..3c502f7 100644
---- a/test/catalog-offers.test.js
++++ b/test/catalog-offers.test.js
@@@ -91,6 +91,17 @@ const item = (key, title, { published = '2026-09-20T10:00:00Z', summary = null,
assert.match(front.text, /Latest hosting offers/);
assert.match(front.text, /href="\/catalog\/offers"/);
});
++ await check('before migration 0009 applies (a held contract holds it), offers answer empty and readiness does not need the table', async () => {
++ const { createCatalog } = require('../server/catalog/store');
++ const missing = Object.assign(new Error('relation "catalog_offers" does not exist'), { code: '42P01' });
++ const db = { prepare: () => ({ all: async () => { throw missing; }, get: async () => null, run: async () => ({ changes: 0 }) }) };
++ const early = createCatalog({ store: { db, now: () => NOW } });
++ assert.deepStrictEqual(await early.offers({}), { offers: [], next_cursor: null });
++ assert.ok(!require('../server/db').CHARTER_TABLES.includes('catalog_offers'), 'readiness does not wait for the table');
++ const other = Object.assign(new Error('boom'), { code: '57014' });
++ const broken = createCatalog({ store: { db: { prepare: () => ({ all: async () => { throw other; } }) }, now: () => NOW } });
++ await assert.rejects(broken.offers({}), /boom/, 'any other error still fails');
++ });
} finally { await t.close(); }
done();
})().catch((e) => { console.error(e); process.exit(1); });