openvibers/openvibe.tips Commits

Tips moves between its pages without a reload (plan T11): openvibe-shared v2.2.0 boost swaps <main> styles-first and does a normal load across a deploy (the ov-boost marker carries the release); page scripts that wire <main> also run on ov:boost:load. Two test:pg flakes the verify found are fixed test-side: the hidden-pending-tip case waits for the settlement to reach the row instead of a fixed 50 ms, and the per-test database teardown waits for terminated sessions to leave and retries catalog DDL on 'tuple concurrently updated'. npm test 16/16; the three files green twice on PostgreSQL.

OP OpenVibers committed 29 Sept 2026 · ebb0aa15cd · 9 files, +46 −13


Changes

ddiff --git a/README.md b/README.md
iindex f6d4bb6..6e3cbe7 100644
---- a/README.md
++++ b/README.md
@@@ -46,7 +46,7 @@ from *interaction delivered*.
(openvibe-sdk transactional outbox)
- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve
- **OpenVibe.Live** — chat delivery through `/internal/tips/deliveries` (deployed in Live since `f11f809`; [docs/live-patch.diff](docs/live-patch.diff) is the original patch) until OpenVibe.Chat exposes paid messages; overlay consumer
--- **OpenVibe.Shared** v2.0.0 (app icon, SSR footer, noscript nav, release manifest, legal pages) and
++- **OpenVibe.Shared** v2.2.0 (app icon, SSR footer, noscript nav, release manifest, legal pages, boost page moves) and
the Network's `navbar.js`; **openvibe-contracts** v0.79.0 and **openvibe-sdk** v0.25.0 (service tokens,
the async data layer `openvibe-sdk/db`, the PostgreSQL outbox and inbox — the events outbox is the SDK's
`createServiceOutbox`, plan T1 — Valkey, pub/sub and the shared limit store), pinned by release tarball;
ddiff --git a/package-lock.json b/package-lock.json
iindex 1e818d4..95c316d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1162,9 +1162,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.0.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
-- "integrity": "sha512-n5CHC0hfxOo0jo/3kS4O3KiwhirhZDlx5zZTIuPyIf3Dq0yI/R6R64btx7NYwZe5dI0NjvqzEtnv19m8unwLMg==",
++ "version": "2.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
++ "integrity": "sha512-1q+iUuUxzPeylTYmithW4jR++Jccvn5GEOrB1B3D18Dgn6OXQhPpvhPQAa1D1k1p+dWIa1/SmggyJwSGFnTNtw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 5e03400..6a8902b 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/js/tips.js b/public/js/tips.js
iindex 74a7bf0..48937fb 100644
---- a/public/js/tips.js
++++ b/public/js/tips.js
@@@ -2,7 +2,7 @@
(function () {
'use strict';
document.documentElement.classList.add('js');
-- document.addEventListener('DOMContentLoaded', function () {
++ function wire() {
var form = document.querySelector('.tip-form');
if (!form) return;
function sync() {
@@@ -11,5 +11,7 @@
}
form.addEventListener('change', sync);
sync();
-- });
++ }
++ document.addEventListener('DOMContentLoaded', wire);
++ document.addEventListener('ov:boost:load', wire); // a swapped-in page's tip form wires the same way
})();
ddiff --git a/server/app.js b/server/app.js
iindex 4a9715b..dcba874 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -57,6 +57,7 @@ function createApp(opts = {}) {
const domain = createDomain({ db, config, outbox, billing, adapters, valkey, now, log });
const apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'tips', root: path.join(__dirname, '..') });
++ require('./web/layout').setRelease(release.release);
const layout = createLayout({ config, release });
const app = express();
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex bfb32b5..de1106b 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -31,6 +31,11 @@ function asset(rel) {
}
const assetVersion = (rel) => { asset(rel); return _hashes.get(rel); };
++// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only
++// between pages of the same release, and does a normal load across a deploy.
++let RELEASE = 'dev';
++function setRelease(id) { if (id) RELEASE = String(id); }
++
const NAV_LINKS = [
{ label: 'Dashboard', href: '/dashboard' },
{ label: 'Receipts', href: '/receipts' },
@@@ -54,7 +59,7 @@ function createLayout({ config, release }) {
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
-- loginUrl: `/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}`,
++ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
};
@@@ -88,6 +93,8 @@ ${jsonLd}
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${asset('js/tips.js')}" defer></script>
++<meta name="ov-boost" content="tips@${esc(RELEASE)}">
++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
</head>
<body>
<div id="navbar-mount"></div>
@@@ -112,4 +119,4 @@ document.addEventListener('DOMContentLoaded', function () {
return { page, abs };
}
--module.exports = { createLayout, esc, asset, assetVersion, SITE_NAME, DEFAULT_DESCRIPTION };
++module.exports = { createLayout, setRelease, esc, asset, assetVersion, SITE_NAME, DEFAULT_DESCRIPTION };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
iindex ce1f289..4f129dc 100644
---- a/test/helpers/db.js
++++ b/test/helpers/db.js
@@@ -56,8 +56,20 @@ async function testDb({ store = process.env.TIPS_TEST_STORE || 'pglite', max = 4
// PgBouncer keeps its server connections after the client pool closes: end them here, or
// every run would leave its roles' idle connections holding the server's slots.
await su.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
-- await su.query(`DROP SCHEMA IF EXISTS ${name} CASCADE`);
-- for (const r of [name, owner]) { await su.query(`DROP OWNED BY ${r}`); await su.query(`DROP ROLE ${r}`); }
++ // Terminating is asynchronous: wait until the sessions are gone, then retry catalog DDL that races another
++ // session's catalog write ('tuple concurrently updated', XX000) a few times.
++ for (let i = 0; i < 100; i++) {
++ const left = await su.query('SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
++ if (!Number(left.rows[0].n)) break;
++ await new Promise((ok) => setTimeout(ok, 50));
++ }
++ const ddl = async (stmt) => {
++ for (let i = 0; ; i++) {
++ try { return await su.query(stmt); } catch (err) { if (err.code !== 'XX000' || i >= 5) throw err; await new Promise((ok) => setTimeout(ok, 100 * (i + 1))); }
++ }
++ };
++ await ddl(`DROP SCHEMA IF EXISTS ${name} CASCADE`);
++ for (const r of [name, owner]) { await ddl(`DROP OWNED BY ${r}`); await ddl(`DROP ROLE ${r}`); }
} finally { await su.close(); }
}
let db;
ddiff --git a/test/moderation.test.js b/test/moderation.test.js
iindex cb7a90e..ba9c07e 100644
---- a/test/moderation.test.js
++++ b/test/moderation.test.js
@@@ -230,7 +230,8 @@ const validModerated = payloadValidator('tips.interaction.moderated');
const id = r.json.interaction.id;
assert.strictEqual((await t.call('POST', `/api/v1/interactions/${id}/hide`, { user: alex })).status, 200);
await t.deliver(billing.settlePurchase((await domain.interactions.get(t.db, id)).billing_intent_id));
-- await new Promise((ok) => setTimeout(ok, 50));
++ // The settlement reaches the row through the delivery; wait for it (bounded) instead of a fixed sleep.
++ for (let k = 0; k < 100 && (await domain.interactions.get(t.db, id)).payment_state === 'pending'; k++) await new Promise((ok) => setTimeout(ok, 20));
await domain.interactions.processDueTransfers();
const i = await domain.interactions.get(t.db, id);
assert.strictEqual(i.payment_state, 'settled');
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 1693959..996e8a6 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -33,6 +33,16 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!r.text.includes('href="/bob"'), 'switched-off pages are not listed');
});
++ await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {
++ for (const [p, u] of [['/', null], ['/alex', viewer], ['/dashboard', alex], ['/receipts', viewer], ['/updates', alex]]) {
++ const r = await get(p, u);
++ assert.strictEqual(r.status, 200, p);
++ assert.match(r.text, /<meta name="ov-boost" content="tips@[^"]+">/, p);
++ assert.match(r.text, /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer><\/script>/, p);
++ assert.match(r.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/, p);
++ }
++ });
++
await check('creator page: indexable when on, goals and a no-JS tip form', async () => {
const r = await get('/alex');
assert.strictEqual(r.status, 200);