openvibers/openvibe.deals Commits

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

OP OpenVibers committed 2 Oct 2026 · f1dfe613ff · 5 files, +136 −52

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

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

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 3047225..053163f 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 9481a49..ffc6922 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 85a84eb..3caee97 100644
---- a/server/http/errors.js
++++ b/server/http/errors.js
@@@ -2,37 +2,32 @@
/**
* 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 Deals row) so every service answers the same way; the exports stay put so no call site moves.
++ * ApiError stays Deals' own refusal (server/domain/values.js): the kit answers any error carrying an
++ * HTTP status and a string code with its own status and code, and anything else as 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');
const { ApiError } = require('../domain/values');
--/** 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;
-- if (err instanceof ApiError) {
-- if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
-- return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });
-- }
-- console.error('[Deals API]', err && err.stack ? err.stack : err);
-- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
-- }
-- };
--}
++const o = { name: 'Deals API', ServiceError: ApiError };
--const jsonParser = express.json({ limit: '64kb' });
--/** 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()));
--}
++/**
++ * Wrap a JSON handler: its return value is the body; errors become problems. The kit's sendError does
++ * not set Retry-After, so an ApiError refusal carrying err.extra.retry_after sets that header here.
++ */
++const run = (fn, status) => svc.run(async (req, res) => {
++ try {
++ return await fn(req, res);
++ } catch (err) {
++ if (err instanceof ApiError && err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
++ throw err;
++ }
++}, status, o);
++
++/** JSON body parser whose failures are problems too: malformed 400, over 64 kB 413, unreadable encoding 415. */
++const jsonBody = svc.jsonBody({ limit: '64kb' });
/** 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 0658fc3..6757003 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -6,29 +6,40 @@
* client secret are set) and the worker (stated expiry, hotness, freshness re-indexing, Sources import).
*/
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: 'Deals', 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(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
-- console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.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(`[Deals] ${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(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
++ console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.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('[Deals] failed to start:', err); process.exit(1); });
}
--process.on('SIGTERM', () => shutdown('SIGTERM'));
--process.on('SIGINT', () => shutdown('SIGINT'));
--})().catch((err) => { console.error('[Deals] 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..b6b076a
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * openvibe-sdk/service (plan T1): Deals' shared JSON body parser answers 413 for a body over 64 kB
++ * (the hand-rolled express.json answered 400 request.invalid_json there) and 415 for an encoding it
++ * cannot read; an ApiError refusal keeps its own status and code and its Retry-After header; and the
++ * entry point's graceful stop runs its stop and close steps, then exits 0.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { boot, check, done } = require('./helpers/boot');
++const { createLifecycle } = require('../server/index');
++const errors = require('../server/http/errors');
++
++(async () => {
++ const t = await boot();
++
++ const app = express();
++ app.post('/echo', errors.jsonBody, errors.run(async () => ({ ok: true })));
++ app.post('/refuse', errors.run(async () => { throw new errors.ApiError(429, 'rate.limited', 'Slow down', { retry_after: 7 }); }));
++ 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 post = (path, body, headers = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
++
++ const read = async (r) => { const text = await r.text(); return { status: r.status, headers: r.headers, text, json: JSON.parse(text) }; };
++
++ await check('a JSON body over 64 kB is 413 request.too_large, not 400 request.invalid_json', async () => {
++ const r = await read(await post('/echo', JSON.stringify({ padding: 'y'.repeat(70 * 1024) })));
++ assert.strictEqual(r.status, 413, r.text);
++ assert.strictEqual(r.json.code, 'request.too_large');
++ });
++
++ await check('malformed JSON is 400 request.invalid_json', async () => {
++ const r = await read(await post('/echo', '{oops'));
++ assert.strictEqual(r.status, 400, r.text);
++ assert.strictEqual(r.json.code, 'request.invalid_json');
++ });
++
++ await check('a Content-Encoding the parser cannot read is 415 request.unsupported_encoding', async () => {
++ const r = await read(await post('/echo', '{}', { 'content-encoding': 'xz' }));
++ assert.strictEqual(r.status, 415, r.text);
++ assert.strictEqual(r.json.code, 'request.unsupported_encoding');
++ });
++
++ await check('an ApiError refusal keeps its status and code and sets Retry-After from extra.retry_after', async () => {
++ const r = await read(await post('/refuse', '{}'));
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.headers.get('retry-after'), '7');
++ assert.strictEqual(r.json.code, 'rate.limited');
++ });
++
++ 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 stopServer = http.createServer(t.app);
++ await new Promise((resolve) => stopServer.listen(0, '127.0.0.1', resolve));
++
++ const exits = [];
++ const lifecycle = createLifecycle({ server: stopServer, 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(stopServer.listening, false);
++ });
++
++ server.close();
++ try { await t.close(); } catch { /* the lifecycle above already closed the store */ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });