openvibers/openvibe.coupons Commits

Move Coupons errors and shutdown onto openvibe-sdk/service (#3)

OP OpenVibers committed 2 Oct 2026 · d96838915a · 5 files, +129 −58

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by space-bunny-alpha · 1 fix round

https://github.com/OpenVibers/OpenVibe.Coupons/pull/3

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 904b0c3..c1c1ada 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
@@@ -1181,9 +1181,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.25.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
-- "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==",
++ "version": "0.26.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
++ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 85bb230..232b6a1 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
ddiff --git a/server/http/errors.js b/server/http/errors.js
iindex e6dda28..e4f501a 100644
---- a/server/http/errors.js
++++ b/server/http/errors.js
@@@ -2,22 +2,18 @@
/**
* Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
-- * and the small request helpers every router shares.
++ * and the small request helpers every router shares. Built from openvibe-sdk/service (docs/service.md,
++ * the Blog row) so every service answers the same way; the exports stay put so no call site moves.
++ * Only an ApiError or a HostError is a refusal: the SDK's default would also echo any error carrying
++ * { status, code }, so anything else reaches it stripped to its stack and answers a generic 500.
*/
--const express = require('express');
--const contracts = require('openvibe-contracts');
++const svc = require('openvibe-sdk/service');
const cache = require('openvibe-shared/cache-policy');
/** A refusal with a stable problem code (e.g. 404 'coupon.not_found'). */
--class ApiError extends Error {
-- constructor(status, code, detail, extra) {
-- super(detail || code);
-- this.name = 'ApiError';
-- this.status = status;
-- this.code = code;
-- this.extra = extra || null;
-- }
--}
++const ApiError = svc.createServiceError('ApiError');
++
++const o = { name: 'Coupons API' };
/** Domain errors (HostError, …) carry { status, code } too. */
function asApiError(err) {
@@@ -26,28 +22,20 @@ function asApiError(err) {
return null;
}
++/** An unexpected error with nothing a client may see: logged by its stack, answered as internal.error. */
++const internal = (err) => ({ stack: err && err.stack ? err.stack : String(err) });
++
/** Wrap a JSON handler: its return value is the body; errors become problems. */
--function run(fn, status = 200) {
-- return async (req, res) => {
-- try {
-- const out = await fn(req, res);
-- if (out === undefined || res.headersSent) return;
-- res.status(typeof status === 'function' ? status(out) : status).json(out);
-- } catch (err) {
-- if (res.headersSent) return;
-- const e = asApiError(err);
-- if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
-- console.error('[Coupons API]', err && err.stack ? err.stack : err);
-- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
-- }
-- };
--}
++const run = (fn, status) => svc.run(async (req, res) => {
++ try {
++ return await fn(req, res);
++ } catch (err) {
++ throw asApiError(err) || internal(err);
++ }
++}, status, o);
--const jsonParser = express.json({ limit: '32kb' });
--/** JSON body parser whose syntax errors are problems too. */
--function jsonBody(req, res, next) {
-- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
--}
++/** JSON body parser whose failures are problems too: malformed 400, over the limit 413. */
++const jsonBody = svc.jsonBody({ limit: '32kb' });
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
function privateNoStore(res) {
ddiff --git a/server/index.js b/server/index.js
iindex 16c16d0..5b2ab5b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -6,29 +6,40 @@
* client secret are set) and the worker (expiry/decay sweep; Sources import when configured).
*/
const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
--(async () => {
--const { app, ctx } = await createApp();
--const { config } = ctx;
++/**
++ * The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new
++ * work, the HTTP drain runs (defaults 4000/5000), then the outbox settles and the store closes; past
++ * the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject
++ * `exit` and `signals: false`.
++ */
++function createLifecycle({ server, ctx, exit, signals }) {
++ return gracefulStop({
++ name: 'Coupons', server, deadlineExitCode: 0, exit, signals,
++ stop: [() => ctx.worker.stop()],
++ close: [() => ctx.outbox.stop(), () => ctx.store.close()],
++ });
++}
--const server = app.listen(config.port, config.host, () => {
-- console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
-- console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);
--});
--server.keepAliveTimeout = 65_000;
--ctx.outbox.start();
--ctx.worker.start();
++async function start() {
++ const { app, ctx } = await createApp();
++ const { config } = ctx;
--function shutdown(signal) {
-- console.log(`[Coupons] ${signal}: closing`);
-- ctx.worker.stop();
-- server.close(async () => {
-- try { await ctx.outbox.stop(); } catch { /* best effort */ }
-- try { await ctx.store.close(); } catch { /* already closed */ }
-- process.exit(0);
++ const server = app.listen(config.port, config.host, () => {
++ console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
++ console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);
});
-- setTimeout(() => process.exit(0), 5000).unref();
++ server.keepAliveTimeout = 65_000;
++ ctx.outbox.start();
++ ctx.worker.start();
++
++ createLifecycle({ server, ctx });
++ return { server, ctx };
++}
++
++if (require.main === module) {
++ start().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });
}
--process.on('SIGTERM', () => shutdown('SIGTERM'));
--process.on('SIGINT', () => shutdown('SIGINT'));
--})().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });
++
++module.exports = { start, createLifecycle };
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..e45ffb3
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * openvibe-sdk/service (plan T1): the shared JSON body parser answers 413 for a body over 32 kB
++ * (the hand-rolled one answered 400 request.invalid_json there), and the entry point's graceful stop
++ * runs its stop and close steps and exits 0. Only an ApiError or a HostError is a refusal: any other error
++ * answers a generic 500, even one carrying { status, code }.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++const express = require('express');
++const { createLifecycle } = require('../server/index');
++const errors = require('../server/http/errors');
++
++(async () => {
++ const t = await boot();
++
++ await check('a JSON body over 32 kB is 413 request.too_large, not 400 request.invalid_json', async () => {
++ const r = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Big', domains: [{ host: 'big.example' }], padding: 'y'.repeat(40 * 1024) } });
++ assert.strictEqual(r.status, 413, r.text);
++ assert.strictEqual(r.json().code, 'request.too_large');
++ });
++
++ await check('an error that is not an ApiError or a HostError never reaches the client, even with { status, code }', async () => {
++ const app = express();
++ app.get('/x', errors.run(() => { const e = new Error('secret internals'); e.status = 409; e.code = 'db.conflict'; throw e; }));
++ app.get('/h', errors.run(() => { const e = new Error('bad host'); e.name = 'HostError'; e.status = 422; e.code = 'host.invalid'; throw e; }));
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const log = console.error;
++ console.error = () => {};
++ try {
++ const leak = await fetch(`${base}/x`);
++ assert.strictEqual(leak.status, 500);
++ const body = await leak.json();
++ assert.strictEqual(body.code, 'internal.error');
++ assert.ok(!JSON.stringify(body).includes('secret'), JSON.stringify(body));
++ const host = await fetch(`${base}/h`);
++ assert.strictEqual(host.status, 422);
++ assert.strictEqual((await host.json()).code, 'host.invalid');
++ } finally {
++ console.error = log;
++ server.close();
++ }
++ });
++
++ await check('the entry point stop runs its stop and close steps, then exits 0', async () => {
++ const steps = [];
++ const spy = (obj, method, label) => {
++ const orig = obj[method].bind(obj);
++ obj[method] = (...a) => { steps.push(label); return orig(...a); };
++ };
++ spy(t.ctx.worker, 'stop', 'worker.stop');
++ spy(t.ctx.outbox, 'stop', 'outbox.stop');
++ spy(t.ctx.store, 'close', 'store.close');
++
++ const server = http.createServer(t.app);
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++
++ const exits = [];
++ const lifecycle = createLifecycle({ server, ctx: t.ctx, exit: (code) => exits.push(code), signals: false });
++ const code = await lifecycle.stop('SIGTERM');
++
++ assert.strictEqual(code, 0);
++ assert.deepStrictEqual(exits, [0]);
++ assert.deepStrictEqual(steps, ['worker.stop', 'outbox.stop', 'store.close']);
++ assert.strictEqual(server.listening, false);
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });