openvibers/openvibe.search Commits

Outbox: openvibe-sdk/events instead of the hand-rolled copy (#37)

OP OpenVibers committed 10 Oct 2026 · 97be63c3bc · 17 files, +212 −270

Plan T1, the kernel: delete the hand-rolled outbox copies. Search's own `server/events/outbox.js` (`createOutbox` + `createRelay`, 163 lines, shared word for word with Watch and Sources) gives way to `openvibe-sdk/events` `createServiceOutbox`.

- **Expand migration `0002_sdk_outbox.sql`:** creates `service_outbox` with exactly the SDK's `outboxSchema('service_outbox')` DDL, then copies every unsent, unrejected row of `event_outbox` into it. `event_outbox` stays for a release that rolls back; Events answers a repeated `event_id` as a duplicate, so delivery stays at least once. A contract migration drops it after the N-1 window.
- **Writes** still join the change's ambient transaction (`emit`), so an event exists if and only if its change committed. `a small envelope helper in server/store.js keeps Search's envelope defaults; `eventTypes` is exactly the manifest's two (search.document.indexed, search.document.removed), and every envelope is validated against `events.event-envelope@1`.
- **Relay:** the SDK's leased relay (FOR UPDATE SKIP LOCKED), Search's client-credentials token (OV_OAUTH_CLIENT_SECRET is set in production), the same interval. Readiness and the outbox gauge read `status()`; an applied document still kicks the relay at once.
- **Coverage:** `test/outbox.test.js` checks commit-or-nothing, the relay posting with the service token, rows waiting while the relay is off, and a row copied by the migration being relayed. test/outbox-migration.test.js proves the copy, and the documents, webhook, purge and ACL suites follow the new wiring.

`ov test` (files covering the change): 21 passed.

Changes

ddiff --git a/README.md b/README.md
iindex 47ab8a5..a7aa16d 100644
---- a/README.md
++++ b/README.md
@@@ -37,7 +37,8 @@ API and the Events webhook are loopback-only, and owners and Events call `127.0.
`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the
database (the documents and the full-text table) fails; a Network signing key that has not loaded
(anonymous queries still answer) and a full-text index out of step with the documents table degrade
--it. It also reports document counts by exposure, the outbox backlog and whether the Events webhook
++it. It also reports document counts by exposure, the SDK outbox status (`enabled`, `pending`,
++`rejected`, `last_error`) and whether the Events webhook
is on. `GET /metrics` (openvibe-shared/metrics) answers direct loopback callers only: golden signals
by route template, `search_documents{exposure}`, `search_documents_indexed{index}` and the outbox
backlog.
@@@ -166,7 +167,8 @@ Every change that lowers a document's exposure (deleted, drafted, unpublished, m
private/members/unlisted, or public → noindex) leaves results in the same transaction and emits
`search.document.removed` (with the canonical URL if it had been public, so caches and sitemaps
purge); every accepted upsert emits `search.document.indexed`. Both go through a transactional
--outbox relayed to Events when `EVENTS_URL` is set.
++outbox (`openvibe-sdk/events`, table `service_outbox`) relayed to Events when `EVENTS_URL` and
++`OV_OAUTH_CLIENT_SECRET` are set. Pending rows wait when the relay is off.
### Removals, caches and the CDN
@@@ -192,8 +194,9 @@ the `search.document.removed` event it records:
- the index document contract (`search.index-document@1`, released in openvibe-contracts v0.12.0; proposal in [docs/contracts-proposal/](docs/contracts-proposal/))
- `documents`, `doc_acl`, `doc_facets`, `search_fts` (the full-text index), `idempotency_receipts`,
-- `ingest_rejections`, `event_outbox`, `removals`, `cdn_purges`, `saved_searches` (PostgreSQL, schema in
++ `ingest_rejections`, `service_outbox` (the SDK outbox), `removals`, `cdn_purges`, `saved_searches` (PostgreSQL, schema in
[migrations/](migrations/); engine decision in [docs/adr-engine.md](docs/adr-engine.md))
++- `event_outbox` remains for the N-1 rollback window; the expand migration copies its pending rows to `service_outbox`.
- the query API, its ranking and ACL filtering, the search page and saved searches; deletion and
visibility-change propagation, including the CDN purge of removed public URLs
ddiff --git a/STATUS.json b/STATUS.json
iindex 2ca11b8..174f8de 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -34,6 +34,7 @@
"server-rendered search page",
"owner removal feed",
"Cloudflare purge queue (inert until CLOUDFLARE_PURGE_TOKEN is set)",
++ "openvibe-sdk/events outbox in service_outbox (legacy event_outbox retained for N-1 rollback)",
"per-actor limits (openvibe-sdk/limits)"
],
"updated": "2026-10-10"
ddiff --git a/migrations/0002_sdk_outbox.sql b/migrations/0002_sdk_outbox.sql
nnew file mode 100644
iindex 0000000..7805f8b
---- /dev/null
++++ b/migrations/0002_sdk_outbox.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++-- openvibe-sdk/events outboxSchema('service_outbox'). The old event_outbox stays through the N-1 rollback window.
++CREATE TABLE IF NOT EXISTS service_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++CREATE INDEX IF NOT EXISTS service_outbox_due ON service_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS service_outbox_sent ON service_outbox (sent_at) WHERE sent_at IS NOT NULL;
++
++INSERT INTO service_outbox (event_id, envelope, created_at, attempts, next_attempt_at)
++SELECT event_id, envelope::jsonb, created_at, attempts, next_attempt_at
++FROM event_outbox WHERE sent_at IS NULL AND rejected_at IS NULL
++ON CONFLICT (event_id) DO NOTHING;
++
++-- A later contract migration drops event_outbox after the N-1 window.
ddiff --git a/server/api/documents.js b/server/api/documents.js
iindex 384f74d..57ef246 100644
---- a/server/api/documents.js
++++ b/server/api/documents.js
@@@ -42,7 +42,7 @@ function outcomeResponse(res, ctx, r) {
return res.json(r);
}
--function documentsRouter({ store, auth, db, relay, purges, limits }) {
++function documentsRouter({ store, auth, db, outbox, purges, limits }) {
const router = express.Router();
const guard = auth.requireCap(CAPS.write);
// Per-actor limits (server/actor-limits.js), by service principal; reconciliation reads take the
@@@ -78,7 +78,7 @@ function documentsRouter({ store, auth, db, relay, purges, limits }) {
const v = validate(doc);
if (!v.valid) return http.sendProblem(res, 422, 'search.bad_document', { detail: 'document does not match search.index-document@1', ctx, errors: v.errors });
const r = await store.apply(doc, { via: 'api', traceId: ctx.traceId });
-- if (r.outcome === 'applied' && relay) relay.flush().catch(() => {});
++ if (r.outcome === 'applied') outbox.kick().catch(() => {});
return outcomeResponse(res, ctx, r);
});
@@@ -91,7 +91,7 @@ function documentsRouter({ store, auth, db, relay, purges, limits }) {
revision = Number(req.query.revision);
}
const r = await store.remove(req.params.owner, req.params.type, req.params.id, { revision, via: 'api', traceId: ctx.traceId });
-- if (r.outcome === 'applied' && relay) relay.flush().catch(() => {});
++ if (r.outcome === 'applied') outbox.kick().catch(() => {});
return outcomeResponse(res, ctx, r);
});
ddiff --git a/server/api/webhook.js b/server/api/webhook.js
iindex 6f7ce89..a8c93a9 100644
---- a/server/api/webhook.js
++++ b/server/api/webhook.js
@@@ -119,7 +119,7 @@ function documentFromEvent(event, owners) {
return { doc };
}
--function webhookRouter({ config, db, store, relay, log = console, now }) {
++function webhookRouter({ config, db, store, outbox, log = console, now }) {
const router = express.Router();
const inbox = createInbox(db, { now });
@@@ -160,7 +160,7 @@ function webhookRouter({ config, db, store, relay, log = console, now }) {
const out = await store.apply(parsed.doc, { via: 'event', eventId: event.event_id, traceId });
return out.outcome;
});
-- if (!r.duplicate && r.outcome === 'applied' && relay) relay.flush().catch(() => {});
++ if (!r.duplicate && r.outcome === 'applied') outbox.kick().catch(() => {});
res.status(200).json({ event_id: event.event_id, duplicate: Boolean(r.duplicate), outcome: r.outcome || null });
} catch (err) { return next(err); } }); // Express 4 does not catch an async handler's rejection
ddiff --git a/server/app.js b/server/app.js
iindex 0aada15..62aa36b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -15,7 +15,7 @@ const { createLimits } = require('./actor-limits');
const { createIndexNow } = require('openvibe-shared/indexnow');
const pkg = require('../package.json');
--function createApp({ config, db, store, engine, auth, outbox, relay, purges, purger, saved, searcher: givenSearcher = null, notifier = null, log = console, now }) {
++function createApp({ config, db, store, engine, auth, outbox, purges, purger, saved, searcher: givenSearcher = null, notifier = null, log = console, now }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
@@@ -49,7 +49,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
app.use('/shared', require('openvibe-shared/serve').handler());
-- app.use(webhookRouter({ config, db, store, relay, log, now }));
++ app.use(webhookRouter({ config, db, store, outbox, log, now }));
app.use('/api', express.json({ limit: config.maxBodyBytes, type: ['application/json', 'application/*+json'] }));
@@@ -59,7 +59,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
// Readiness (openvibe-shared/ready): 503 only when the database (documents and full-text tables)
// fails; the Network key and index consistency are optional and degrade it (see observability.js).
-- const readiness = createSearchReadiness({ db, config, engine, store, outbox, relay, purges, purger, saved, notifier, release: release.release });
++ const readiness = createSearchReadiness({ db, config, engine, store, outbox, purges, purger, saved, notifier, release: release.release });
app.get('/api/ready', readiness.handler);
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
@@@ -68,7 +68,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
// One per-actor limiter for the API routes below (server/actor-limits.js); the Events webhook,
// health, ready, release.json and metrics above are never limited.
const limits = createLimits({ config, now: now || (() => Date.now()), registry: metrics.registry, log });
-- app.use(documentsRouter({ store, auth, db, relay, purges, limits }));
++ app.use(documentsRouter({ store, auth, db, outbox, purges, limits }));
app.use(queryRouter({ config, store, engine, auth, searcher, limits }));
app.use(savedRouter({ config, saved, searcher, auth, limits }));
// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
ddiff --git a/server/config.js b/server/config.js
iindex 0825956..c787387 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -75,7 +75,7 @@ function load(env = process.env) {
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
-- // Events: the outbox relays only when EVENTS_URL is set (rows wait otherwise).
++ // Events: the SDK outbox relays with EVENTS_URL and the OAuth client secret (rows wait otherwise).
events: {
url: strip(env.EVENTS_URL || ''),
relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
ddeleted file mode 100644
iindex 26729a8..0000000
---- a/server/events/outbox.js
++++ /dev/null
@@@ -1,163 +0,0 @@
--'use strict';
--/**
-- * Transactional outbox (events.event-envelope@1), OpenVibe.Events client semantics:
-- *
-- * enqueue(envelope) INSIDE the transaction that makes the change, so an event exists exactly
-- * when its effect does (throws outside a transaction)
-- * relay publishes unsent rows to OpenVibe.Events (POST /api/v1/events, service
-- * token for audience openvibe.events with events.event.publish), marks them
-- * sent, backs off on failure; a permanently refused row (4xx other than
-- * 401/408/429) is marked rejected so it cannot block the rest
-- *
-- * The relay runs only when EVENTS_URL is set; without it rows wait and are relayed later. Events
-- * deduplicates on event_id, so a retry after a lost response never publishes twice.
-- */
--const { ids, validate, serviceAuth } = require('openvibe-contracts');
--
--const BACKOFF_MS = [1000, 5000, 30000, 120000, 600000];
--
--function createOutbox(db, { source, now = () => Date.now() }) {
-- const insert = db.prepare('INSERT INTO event_outbox (event_id, event_type, envelope, created_at) VALUES (?, ?, ?, ?)');
--
-- /** { event_type, subject, payload, visibility?, priority?, actor?, trace_id? } → envelope */
-- async function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actor, trace_id }) {
-- if (!db.inTransaction()) throw new Error('outbox.enqueue() must run inside the transaction that makes the change');
-- const ms = now();
-- const env = {
-- event_id: ids.newId('event', ms),
-- event_type,
-- version: 1,
-- source,
-- actor: actor || { type: 'service', id: source },
-- timestamp: new Date(ms).toISOString(),
-- priority,
-- visibility,
-- subject,
-- payload: payload || {},
-- };
-- if (trace_id && /^[0-9a-f]{32}$/.test(trace_id)) env.trace_id = trace_id;
-- const v = validate('events.event-envelope@1', env);
-- if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
-- await insert.run(env.event_id, event_type, JSON.stringify(env), ms);
-- return env;
-- }
--
-- const q = {
-- due: db.prepare('SELECT seq, event_id, envelope, attempts FROM event_outbox WHERE sent_at IS NULL AND rejected_at IS NULL AND next_attempt_at <= ? ORDER BY seq LIMIT ?'),
-- sent: db.prepare('UPDATE event_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?'),
-- failed: db.prepare('UPDATE event_outbox SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? WHERE seq = ?'),
-- rejected: db.prepare('UPDATE event_outbox SET rejected_at = ?, attempts = attempts + 1, last_error = ? WHERE seq = ?'),
-- pending: db.prepare('SELECT COUNT(*) AS n FROM event_outbox WHERE sent_at IS NULL AND rejected_at IS NULL'),
-- rejectedCount: db.prepare('SELECT COUNT(*) AS n FROM event_outbox WHERE rejected_at IS NOT NULL'),
-- list: db.prepare('SELECT envelope FROM event_outbox ORDER BY seq'),
-- prune: db.prepare('DELETE FROM event_outbox WHERE sent_at IS NOT NULL AND sent_at < ?'),
-- };
--
-- return {
-- enqueue,
-- pending: async () => (await q.pending.get()).n,
-- rejected: async () => (await q.rejectedCount.get()).n,
-- /** Every envelope in order (tests and operators). */
-- all: async () => (await q.list.all()).map(r => JSON.parse(r.envelope)),
-- prune: async (olderThanMs = 7 * 24 * 3600 * 1000) => (await q.prune.run(now() - olderThanMs)).changes,
-- _q: q,
-- };
--}
--
--/**
-- * createRelay({ db, outbox, eventsUrl, intervalMs?, tokenClient? | tokenOpts?, fetchImpl?, log?, now? })
-- * → { start, stop, flush, running }
-- * tokenOpts = { tokenUrl, clientId, clientSecret } builds a client-credentials token client for
-- * audience openvibe.events. Without eventsUrl or a token source the relay does nothing.
-- */
--function createRelay({ outbox, eventsUrl, intervalMs = 2000, tokenClient, tokenOpts, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), db }) {
-- const q = outbox._q;
-- const tokens = tokenClient || (tokenOpts && serviceAuth.createTokenClient({ ...tokenOpts, audience: 'openvibe.events', scope: 'events.event.publish', fetchImpl }));
-- let timer = null;
-- let flushing = null;
--
-- function permanent(status) {
-- return status >= 400 && status < 500 && ![401, 408, 429].includes(status);
-- }
--
-- async function post(rows) {
-- const body = rows.length === 1 ? JSON.parse(rows[0].envelope) : { events: rows.map(r => JSON.parse(r.envelope)) };
-- const res = await fetchImpl(`${eventsUrl}/api/v1/events`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
-- body: JSON.stringify(body),
-- signal: AbortSignal.timeout(10000),
-- });
-- if (res.status === 401 && typeof tokens.invalidate === 'function') tokens.invalidate();
-- const text = await res.text().catch(() => '');
-- return { ok: res.ok, status: res.status, text };
-- }
--
-- async function mark(rows, fn) { await db.tx(async () => { for (const r of rows) await fn(r); }); }
--
-- async function publishRows(rows) {
-- let r;
-- try {
-- r = await post(rows);
-- } catch (err) {
-- await mark(rows, async row => await q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], String(err.message).slice(0, 500), row.seq));
-- return { sent: 0, failed: rows.length, rejected: 0 };
-- }
-- if (r.ok) {
-- await mark(rows, async row => await q.sent.run(now(), row.seq));
-- return { sent: rows.length, failed: 0, rejected: 0 };
-- }
-- if (permanent(r.status) && rows.length > 1) {
-- // One bad envelope refuses the batch: isolate it.
-- const total = { sent: 0, failed: 0, rejected: 0 };
-- for (const row of rows) {
-- const s = await publishRows([row]);
-- total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;
-- }
-- return total;
-- }
-- const msg = `${r.status} ${r.text.slice(0, 300)}`;
-- if (permanent(r.status)) {
-- await mark(rows, async row => await q.rejected.run(now(), msg, row.seq));
-- log.warn(`[outbox] event refused by Events: ${msg}`);
-- return { sent: 0, failed: 0, rejected: rows.length };
-- }
-- await mark(rows, async row => await q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], msg, row.seq));
-- return { sent: 0, failed: rows.length, rejected: 0 };
-- }
--
-- async function doFlush() {
-- const total = { sent: 0, failed: 0, rejected: 0 };
-- if (!eventsUrl || !tokens) return total;
-- for (;;) {
-- const rows = await q.due.all(now(), 50);
-- if (!rows.length) break;
-- const s = await publishRows(rows);
-- total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;
-- if (s.failed || rows.length < 50) break;
-- }
-- if (total.failed) log.warn(`[outbox] ${total.failed} event(s) not published yet; will retry`);
-- return total;
-- }
--
-- function flush() {
-- if (!flushing) flushing = doFlush().finally(() => { flushing = null; });
-- return flushing;
-- }
--
-- function start() {
-- if (timer || !eventsUrl) return;
-- timer = setInterval(() => { flush().catch(err => log.warn(`[outbox] relay: ${err.message}`)); }, intervalMs);
-- timer.unref?.();
-- }
--
-- function stop() {
-- if (timer) clearInterval(timer);
-- timer = null;
-- return flushing || Promise.resolve();
-- }
--
-- return { start, stop, flush, running: () => Boolean(timer) };
--}
--
--module.exports = { createOutbox, createRelay };
ddiff --git a/server/index.js b/server/index.js
iindex ab06726..79009c1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -11,7 +11,8 @@ const { load } = require('./config');
const { openDb } = require('./db');
const { createEngine } = require('./engine/pg');
const { createStore } = require('./store');
--const { createOutbox, createRelay } = require('./events/outbox');
++const { createServiceOutbox } = require('openvibe-sdk/events');
++const contracts = require('openvibe-contracts');
const { jwksClient } = require('openvibe-sdk/auth');
const { gracefulStop } = require('openvibe-sdk/service');
const { createAuth } = require('./auth');
@@@ -22,7 +23,7 @@ const { createNetworkPush } = require('./network-push');
const { createSavedNotifier } = require('./saved-notifier');
const { createApp } = require('./app');
--async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, networkPush = null, log = console, listen = true } = {}) {
++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, networkPush = null, log = console, listen = true } = {}) {
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
@@@ -30,16 +31,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
// The index is derived from documents: rebuilt at boot when it disagrees with the documents table.
const rebuilt = await engine.reconcile();
if (rebuilt.rebuilt) log.log(`[search] full-text index rebuilt: ${rebuilt.rebuilt} document(s)`);
-- const outbox = createOutbox(db, { source: config.serviceId, now });
++ const outbox = createServiceOutbox({
++ db, source: config.serviceId, eventsUrl: config.events.url, networkInternalUrl: config.networkInternalUrl,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ intervalMs: config.events.relayIntervalMs, log, table: 'service_outbox', now, fetch: fetchImpl,
++ eventTypes: ['search.document.indexed', 'search.document.removed'],
++ validate: (env) => contracts.validate('events.event-envelope@1', env),
++ });
const purges = createPurgeQueue(db, { config: config.purge, now });
const purger = createPurger({ db, config: config.purge, fetchImpl, log, now });
const saved = createSavedSearches(db, { maxPerSubject: config.savedSearches.maxPerSubject, now });
const store = createStore({ db, engine, outbox, purges, now });
-- const relay = createRelay({
-- db, outbox, eventsUrl: config.events.url, intervalMs: config.events.relayIntervalMs, fetchImpl, log, now,
-- tokenClient,
-- tokenOpts: config.oauth.clientSecret ? { tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret } : null,
-- });
// Identity keys: the SDK keeps one JWKS client per URL (openvibe-sdk/auth). It serves the last good
// keys through an outage, refetches at once for an unknown kid (a rotation) and backs off on failure.
const jwksUrl = `${config.networkInternalUrl}/api/.well-known/jwks`;
@@@ -51,16 +53,16 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
config, saved, searcher, now, log,
network: networkPush || createNetworkPush({ config, fetchImpl }),
});
-- const app = createApp({ config, db, store, engine, auth, outbox, relay, purges, purger, saved, searcher, notifier, log, now });
++ const app = createApp({ config, db, store, engine, auth, outbox, purges, purger, saved, searcher, notifier, log, now });
// The background refresher is not started under the test runtime (the tests stub the fetch and load
// the keys once below); a real boot refreshes on an unref'd timer. keyLoaded settles the first load.
if (config.nodeEnv !== 'test') jwks.start();
const keyLoaded = jwks.keys().catch(() => null);
-- relay.start();
++ outbox.start();
purger.start();
notifier.start();
-- const pruneTimer = setInterval(async () => { try { await outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
++ const pruneTimer = setInterval(async () => { try { await outbox.outbox.prune(); } catch (err) { log.error(`[outbox] prune: ${err.message}`); } }, 6 * 3600 * 1000);
pruneTimer.unref?.();
let server = null;
@@@ -75,7 +77,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
async function close() {
clearInterval(pruneTimer);
jwks.stop();
-- await relay.stop();
++ await outbox.stop();
await purger.stop();
await notifier.stop();
if (server) {
@@@ -85,7 +87,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
}
-- return { config, db, engine, store, outbox, relay, purges, purger, saved, searcher, notifier, jwks, keyLoaded, auth, app, server, close };
++ return { config, db, engine, store, outbox, purges, purger, saved, searcher, notifier, jwks, keyLoaded, auth, app, server, close };
}
if (require.main === module) {
ddiff --git a/server/observability.js b/server/observability.js
iindex 1a67b0f..29711cb 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -35,7 +35,7 @@ function readers(db, engine) {
};
}
--function createSearchReadiness({ db, config, engine, store, outbox, relay, purges = null, purger = null, saved = null, notifier = null, release = null }) {
++function createSearchReadiness({ db, config, engine, store, outbox, purges = null, purger = null, saved = null, notifier = null, release = null }) {
const read = readers(db, engine);
return createReadiness({
service: 'search',
@@@ -83,7 +83,7 @@ function createSearchReadiness({ db, config, engine, store, outbox, relay, purge
return {
engine: engine.name,
documents: dbOk ? await store.counts() : null,
-- outbox: dbOk ? { pending: await outbox.pending(), rejected: await outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,
++ outbox: dbOk ? await outbox.status() : null,
webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',
purge: dbOk && purges ? {
cdn: purges.cdnOn() ? (purger && purger.running() ? 'cloudflare' : 'cloudflare (stopped)') : 'off (CLOUDFLARE_PURGE_TOKEN unset)',
@@@ -110,8 +110,8 @@ function registerSearchGauges(registry, { db, engine, outbox, purges = null }) {
name: 'search_documents_indexed', help: 'Documents in the full-text index, by audience', labelNames: ['index'],
collect: async () => Object.entries(await read.indexed()).map(([index, value]) => ({ labels: { index }, value })),
});
-- registry.gauge({ name: 'search_outbox_pending', help: 'Events waiting in the outbox', collect: async () => await outbox.pending() });
-- registry.gauge({ name: 'search_outbox_rejected', help: 'Events OpenVibe.Events refused for good', collect: async () => await outbox.rejected() });
++ registry.gauge({ name: 'search_outbox_pending', help: 'Events waiting in the outbox', collect: async () => (await outbox.status()).pending });
++ registry.gauge({ name: 'search_outbox_rejected', help: 'Events OpenVibe.Events refused for good', collect: async () => (await outbox.status()).rejected });
if (purges) {
registry.gauge({
name: 'search_cdn_purges', help: 'Cloudflare cache purges of removed documents, by state', labelNames: ['state'],
ddiff --git a/server/store.js b/server/store.js
iindex c195c47..4da86d7 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -85,6 +85,16 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
return 'noindex';
}
++ /** Keep Search's envelope defaults while the SDK fills event_id, source, version and timestamp. */
++ function eventEnvelope(event_type, subject, payload, traceId) {
++ return {
++ event_type, subject, payload,
++ actor: { type: 'service', id: 'search' },
++ priority: 'important', visibility: 'internal',
++ ...(traceId && /^[0-9a-f]{32}$/.test(traceId) ? { trace_id: traceId } : {}),
++ };
++ }
++
async function announce(prevRow, prevDoc, doc, exposure, traceId) {
const prevExposure = prevRow ? prevRow.exposure : EXPOSURE.none;
const subject = { type: 'document', id: `${doc.owner}/${doc.type}/${doc.id}`, revision: doc.revision };
@@@ -98,22 +108,17 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
// Only a URL that was public already: caches and sitemaps purge it.
canonical_url: wasPublic ? prevRow.canonical_url : null,
};
-- const env = await outbox.enqueue({ event_type: 'search.document.removed', subject, trace_id: traceId, payload });
++ const env = await outbox.emit(eventEnvelope('search.document.removed', subject, payload, traceId));
// Search's own consumer: the owners' removal feed and the CDN purge queue (purge.js).
if (purges) await purges.record(env.event_id, payload);
}
if (!doc.deleted && exposure > EXPOSURE.none) {
-- await outbox.enqueue({
-- event_type: 'search.document.indexed',
-- subject,
-- trace_id: traceId,
-- payload: {
++ await outbox.emit(eventEnvelope('search.document.indexed', subject, {
owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,
exposure: EXPOSURE_NAME[exposure],
reindexed: Boolean(prevRow),
canonical_url: exposure >= EXPOSURE.public_unlisted ? doc.canonical_url : null,
-- },
-- });
++ }, traceId));
}
}
ddiff --git a/test/acl-leak.test.js b/test/acl-leak.test.js
iindex 5800d9f..6a76815 100644
---- a/test/acl-leak.test.js
++++ b/test/acl-leak.test.js
@@@ -5,7 +5,7 @@
*/
const assert = require('assert');
const { ids } = require('openvibe-contracts');
--const { boot, request, serviceToken, userToken, doc, suite } = require('./helpers');
++const { boot, request, serviceToken, userToken, doc, suite, outboxEvents } = require('./helpers');
const t = suite('acl-leak');
let svc;
@@@ -215,7 +215,7 @@ t('a visibility change or deletion leaves results at once and announces search.d
await request(svc.base, 'DELETE', '/api/v1/documents/wiki/page/flip?revision=3', { token: WIKI });
assert.deepStrictEqual(idsOf(await q('q=iotaword', asUser(alice))), []);
-- const removed = (await svc.outbox.all()).filter(e => e.event_type === 'search.document.removed' && e.payload.id === 'flip');
++ const removed = (await outboxEvents(svc.db)).filter(e => e.event_type === 'search.document.removed' && e.payload.id === 'flip');
assert.strictEqual(removed.length, 2);
assert.deepStrictEqual(removed.map(e => [e.payload.reason, e.payload.previous_exposure, e.payload.exposure]), [
['visibility_changed', 'public_listed', 'restricted'],
@@@ -233,7 +233,7 @@ t('publishing → draft and public → noindex also announce removal', async ()
const d2 = doc({ id: 'noidx', title: 'Lambdaword', revision: 1 });
await put(d2);
await put({ ...d2, revision: 2, indexability: { decision: 'noindex', reasons: ['duplicate_without_canonical'] } });
-- const reasons = (await svc.outbox.all()).filter(e => e.event_type === 'search.document.removed' && ['unpub', 'noidx'].includes(e.payload.id)).map(e => `${e.payload.id}:${e.payload.reason}`);
++ const reasons = (await outboxEvents(svc.db)).filter(e => e.event_type === 'search.document.removed' && ['unpub', 'noidx'].includes(e.payload.id)).map(e => `${e.payload.id}:${e.payload.reason}`);
assert.deepStrictEqual(reasons, ['unpub:not_published', 'noidx:noindex']);
assert.deepStrictEqual(idsOf(await q('q=kappaword')), []);
assert.deepStrictEqual(idsOf(await q('q=lambdaword')), []);
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 1659d7f..1a10c8a 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -43,7 +43,7 @@ function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openv
}, key);
}
--async function boot({ env = {}, fetchImpl, jwksFetch, tokenClient, networkPush, now } = {}) {
++async function boot({ env = {}, fetchImpl, jwksFetch, networkPush, now } = {}) {
const config = load({
NODE_ENV: 'test',
PORT: '0',
@@@ -55,7 +55,7 @@ async function boot({ env = {}, fetchImpl, jwksFetch, tokenClient, networkPush,
const inner = fetchImpl || globalThis.fetch;
const jwks = jwksFetch || stubJwks;
const fetchImplBoth = async (url, opts) => String(url).includes('/api/.well-known/jwks') ? jwks(url, opts) : inner(url, opts);
-- const h = await start({ config, db: testdb.db, log: silent, fetchImpl: fetchImplBoth, tokenClient, networkPush, now });
++ const h = await start({ config, db: testdb.db, log: silent, fetchImpl: fetchImplBoth, networkPush, now });
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, async stop() { await h.close(); await testdb.close(); } };
}
@@@ -71,6 +71,12 @@ async function request(base, method, p, { token, body, headers = {}, raw } = {})
return { status: res.status, body: json, text, headers: res.headers };
}
++/** Read the SDK outbox's stored envelopes for event assertions. */
++async function outboxEvents(db) {
++ const rows = await db.many('SELECT envelope FROM service_outbox ORDER BY id');
++ return rows.map(r => typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope);
++}
++
/** A valid, public, published, indexable document. Override anything. */
function doc(overrides = {}) {
return {
@@@ -143,5 +149,5 @@ function suite(name) {
module.exports = {
ISSUER, WEBHOOK_SECRET, privateKey, publicKey, silent, serviceToken, userToken, boot, request,
-- doc, indexEvent, deliver, suite, jwksDocument, stubJwks, JWKS_KID,
++ doc, indexEvent, deliver, suite, jwksDocument, stubJwks, JWKS_KID, outboxEvents,
};
ddiff --git a/test/outbox-migration.test.js b/test/outbox-migration.test.js
nnew file mode 100644
iindex 0000000..f284ccf
---- /dev/null
++++ b/test/outbox-migration.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++/** Migration from the legacy outbox to the SDK table, including delivery of a copied row. */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const os = require('os');
++const { ids, validate } = require('openvibe-contracts');
++const { createDb } = require('openvibe-sdk/db');
++const { createServiceOutbox } = require('openvibe-sdk/events');
++const { load } = require('../server/config');
++const { start } = require('../server/index');
++const { testDb } = require('./db');
++const { suite, doc, outboxEvents } = require('./helpers');
++
++const t = suite('outbox-migration');
++
++t('service writes valid SDK outbox rows in the document transaction', async () => {
++ const testdb = await testDb();
++ const config = load({ NODE_ENV: 'test', SEARCH_EVENTS_SECRET: 'whsec_test_' + 'x'.repeat(40) });
++ const svc = await start({ config, db: testdb.db, listen: false, log: { log() {}, warn() {}, error() {} }, fetchImpl: async () => ({ ok: true, json: async () => ({ keys: [] }) }) });
++ try {
++ const d = doc();
++ await svc.store.apply(d);
++ const all = await outboxEvents(svc.db);
++ assert.strictEqual(all.length, 1);
++ assert.strictEqual(all[0].event_type, 'search.document.indexed');
++ assert.ok(validate('events.event-envelope@1', all[0]).valid);
++ await assert.rejects(svc.db.tx(async () => {
++ await svc.store.apply(doc({ id: 'rollback' }));
++ throw new Error('rollback');
++ }), /rollback/);
++ assert.strictEqual((await outboxEvents(svc.db)).length, 1);
++ assert.strictEqual((await svc.outbox.status()).pending, 1);
++ } finally { await svc.close(); await testdb.close(); }
++});
++
++t('a legacy pending row copied by the migration is relayed', async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'search-outbox-'));
++ const first = path.join(dir, 'first');
++ fs.mkdirSync(first);
++ fs.copyFileSync(path.join(__dirname, '..', 'migrations', '0001_initial.sql'), path.join(first, '0001_initial.sql'));
++ const db = createDb({ pglite: true, service: 'search-outbox-test' });
++ try {
++ await db.migrate({ dir: first });
++ const eventId = ids.newId('event');
++ const env = { event_id: eventId, event_type: 'search.document.indexed', version: 1, source: 'search', actor: { type: 'service', id: 'search' }, timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type: 'document', id: 'wiki/page/legacy', revision: 1 }, payload: { owner: 'wiki', type: 'page', id: 'legacy', revision: 1, exposure: 'public_listed', reindexed: false, canonical_url: 'https://openvibe.wiki/p/legacy' } };
++ assert.ok(validate('events.event-envelope@1', env).valid);
++ await db.query('INSERT INTO event_outbox (event_id, event_type, envelope, created_at, attempts, next_attempt_at) VALUES ($1, $2, $3, $4, 2, 0)', [eventId, env.event_type, JSON.stringify(env), Date.now()]);
++ await db.migrate({ dir: path.join(__dirname, '..', 'migrations') });
++ const calls = [];
++ const fetchImpl = async (url, opts) => {
++ if (String(url).endsWith('/oauth/token')) return new Response(JSON.stringify({ access_token: 'relay-token', expires_in: 300 }), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ calls.push({ url: String(url), headers: opts.headers, body: JSON.parse(opts.body) });
++ return new Response(JSON.stringify({ event_id: eventId, seq: 17 }), { status: 201, headers: { 'Content-Type': 'application/json' } });
++ };
++ const outbox = createServiceOutbox({ db, source: 'search', table: 'service_outbox', eventsUrl: 'http://events.test', networkInternalUrl: 'http://network.test', clientId: 'search', clientSecret: 'test-secret', fetch: fetchImpl });
++ assert.strictEqual((await outbox.status()).pending, 1);
++ await outbox.outbox.flush();
++ assert.strictEqual((await outbox.status()).pending, 0);
++ assert.strictEqual(calls[0].body.event_id, eventId);
++ assert.strictEqual(new Headers(calls[0].headers).get('authorization'), 'Bearer relay-token');
++ assert.strictEqual(await db.value('SELECT attempts FROM service_outbox WHERE event_id = $1', [eventId]), 3);
++ } finally { await db.close(); fs.rmSync(dir, { recursive: true, force: true }); }
++});
++
++t.run();
ddiff --git a/test/outbox.test.js b/test/outbox.test.js
iindex 71152bc..9120274 100644
---- a/test/outbox.test.js
++++ b/test/outbox.test.js
@@@ -1,108 +1,106 @@
'use strict';
--/** Outbox relay: removal events reach OpenVibe.Events, failures back off, refusals do not block. */
++/** SDK outbox wiring: transactional writes, relay, retry, and readiness. */
const assert = require('assert');
const http = require('http');
const { validate } = require('openvibe-contracts');
--const { boot, request, serviceToken, doc, suite } = require('./helpers');
++const { boot, request, serviceToken, doc, suite, outboxEvents } = require('./helpers');
const t = suite('outbox');
++const WIKI = serviceToken('wiki', ['search.document.write']);
++const relayFetch = (url, opts) => String(url).endsWith('/oauth/token')
++ ? Promise.resolve(new Response(JSON.stringify({ access_token: 'relay-token', expires_in: 300 }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
++ : fetch(url, opts);
--/** Stub OpenVibe.Events: records publishes; `respond(body, n)` → status. */
async function stubEvents(respond = () => 201) {
const calls = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => {
-- const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');
++ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
calls.push({ headers: req.headers, body });
-- const status = respond(body, calls.length);
-- res.statusCode = status;
-- res.setHeader('Content-Type', 'application/json');
-- res.end(JSON.stringify(status < 300 ? { ok: true } : { code: 'events.bad_request' }));
++ const status = respond(body);
++ const list = body.events || [body];
++ res.writeHead(status, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(status < 300 ? (body.events
++ ? { results: list.map((e, i) => ({ event_id: e.event_id, seq: i + 1 })) }
++ : { event_id: body.event_id, seq: 1 }) : { code: 'events.bad_request' }));
});
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
-- return { calls, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(r => server.close(() => r())) };
++ return { calls, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(r => server.close(r)) };
}
--const tokenClient = { async authHeaders() { return { Authorization: 'Bearer relay-token' }; }, invalidate() {} };
--const WIKI = serviceToken('wiki', ['search.document.write']);
--
--t('outbox envelopes are valid events.event-envelope@1 and are enqueued only with the change', async () => {
++t('events are valid, commit with the document, and roll back with a failed change', async () => {
const svc = await boot();
try {
-- const d = doc({ canonical_url: 'https://openvibe.wiki/p/a' });
++ const d = doc();
await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });
-- await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: { ...d, revision: 0 } }); // stale: no event
++ await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: { ...d, revision: 0 } });
await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=2`, { token: WIKI });
-- const all = await svc.outbox.all();
++ const all = await outboxEvents(svc.db);
assert.deepStrictEqual(all.map(e => e.event_type), ['search.document.indexed', 'search.document.removed']);
-- for (const e of all) assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(validate('events.event-envelope@1', e).errors));
++ for (const e of all) assert.ok(validate('events.event-envelope@1', e).valid);
assert.strictEqual(all[1].subject.id, `wiki/page/${d.id}`);
assert.strictEqual(all[1].subject.revision, 2);
-- await assert.rejects(async () => await svc.outbox.enqueue({ event_type: 'search.document.indexed', subject: { type: 'document', id: 'x' } }), /inside the transaction/);
-- } finally {
-- await svc.stop();
-- }
++ await assert.rejects(svc.db.tx(async () => {
++ await svc.outbox.emit({ event_type: 'search.document.indexed', subject: { type: 'document', id: 'wiki/page/rollback', revision: 1 }, actor: { type: 'service', id: 'search' }, priority: 'important', visibility: 'internal', payload: {} });
++ throw new Error('rollback');
++ }), /rollback/);
++ assert.strictEqual((await svc.outbox.status()).pending, 2);
++ } finally { await svc.stop(); }
});
--t('the relay publishes pending events with the service token and marks them sent', async () => {
++t('rows wait while the relay is off', async () => {
++ const svc = await boot();
++ try {
++ await request(svc.base, 'PUT', '/api/v1/documents/wiki/page/waiting', { token: WIKI, body: doc({ id: 'waiting' }) });
++ assert.strictEqual((await svc.outbox.status()).enabled, false);
++ await svc.outbox.kick();
++ assert.strictEqual((await svc.outbox.status()).pending, 1);
++ } finally { await svc.stop(); }
++});
++
++t('relay posts with a service token and marks rows sent', async () => {
const events = await stubEvents();
-- const svc = await boot({ env: { EVENTS_URL: events.url }, tokenClient });
++ const svc = await boot({ env: { EVENTS_URL: events.url, OV_OAUTH_CLIENT_SECRET: 'test-secret' }, fetchImpl: relayFetch });
try {
const d = doc();
await request(svc.base, 'PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });
await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=2`, { token: WIKI });
-- // a write kicks the relay; a flush already in flight may predate the second row
-- for (let i = 0; i < 5 && await svc.outbox.pending(); i++) await svc.relay.flush();
-- assert.strictEqual(await svc.outbox.pending(), 0);
-- const published = events.calls.flatMap(c => (c.body.events ? c.body.events : [c.body]));
-- assert.deepStrictEqual(published.map(e => e.event_type).sort(), ['search.document.indexed', 'search.document.removed']);
++ for (let i = 0; i < 5 && (await svc.outbox.status()).pending; i++) await svc.outbox.outbox.flush();
++ assert.strictEqual((await svc.outbox.status()).pending, 0);
++ assert.deepStrictEqual(events.calls.flatMap(c => c.body.events || [c.body]).map(e => e.event_type).sort(), ['search.document.indexed', 'search.document.removed']);
assert.ok(events.calls.every(c => c.headers.authorization === 'Bearer relay-token'));
-- } finally {
-- await svc.stop();
-- await events.close();
-- }
++ } finally { await svc.stop(); await events.close(); }
});
--t('an unreachable or failing Events keeps rows pending for retry; a refused row does not block the rest', async () => {
++t('retryable failures wait and refused envelopes do not block others', async () => {
let mode = 'down';
-- const events = await stubEvents((body) => {
-- if (mode === 'down') return 503;
-- const list = body.events || [body];
-- return list.some(e => e.payload && e.payload.id === 'poison') ? 422 : 201;
-- });
-- const svc = await boot({ env: { EVENTS_URL: events.url }, tokenClient });
++ const events = await stubEvents(body => mode === 'down' ? 503 : (body.events || [body]).some(e => e.payload.id === 'poison') ? 422 : 201);
++ const svc = await boot({ env: { EVENTS_URL: events.url, OV_OAUTH_CLIENT_SECRET: 'test-secret' }, fetchImpl: relayFetch });
try {
await request(svc.base, 'PUT', '/api/v1/documents/wiki/page/poison', { token: WIKI, body: doc({ id: 'poison' }) });
await request(svc.base, 'PUT', '/api/v1/documents/wiki/page/fine', { token: WIKI, body: doc({ id: 'fine' }) });
-- await svc.relay.flush();
-- await svc.relay.flush();
-- assert.strictEqual(await svc.outbox.pending(), 2, 'kept for retry');
++ await svc.outbox.outbox.flush();
++ assert.strictEqual((await svc.outbox.status()).pending, 2);
mode = 'up';
-- await svc.db.prepare('UPDATE event_outbox SET next_attempt_at = 0').run();
-- await svc.relay.flush();
-- await svc.relay.flush();
-- assert.strictEqual(await svc.outbox.pending(), 0);
-- assert.strictEqual(await svc.outbox.rejected(), 1, 'the poison row is set aside');
-- } finally {
-- await svc.stop();
-- await events.close();
-- }
++ await svc.db.exec('UPDATE service_outbox SET next_attempt_at = 0');
++ await svc.outbox.outbox.flush();
++ assert.strictEqual((await svc.outbox.status()).pending, 0);
++ assert.strictEqual((await svc.outbox.status()).rejected, 1);
++ } finally { await svc.stop(); await events.close(); }
});
--t('readiness reports the outbox and the webhook state', async () => {
++t('readiness reports the SDK outbox status', async () => {
const svc = await boot();
try {
const r = await request(svc.base, 'GET', '/api/ready');
assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.body.engine, 'postgresql-fts');
-- assert.match(r.body.outbox.relay, /^off/);
++ assert.strictEqual(r.body.outbox.enabled, false);
++ assert.strictEqual(r.body.outbox.pending, 0);
assert.strictEqual(r.body.webhook, 'on');
-- } finally {
-- await svc.stop();
-- }
++ } finally { await svc.stop(); }
});
t.run();
ddiff --git a/test/purge.test.js b/test/purge.test.js
iindex a712066..a5c77bd 100644
---- a/test/purge.test.js
++++ b/test/purge.test.js
@@@ -4,7 +4,7 @@
* of formerly public URLs against a mocked Cloudflare API. No test talks to Cloudflare.
*/
const assert = require('assert');
--const { boot, request, serviceToken, doc, suite } = require('./helpers');
++const { boot, request, serviceToken, doc, suite, outboxEvents } = require('./helpers');
const { load } = require('../server/config');
const { zoneFor, purgeUrls, MAX_ATTEMPTS } = require('../server/purge');
@@@ -71,7 +71,7 @@ t('inert without CLOUDFLARE_PURGE_TOKEN: removals are recorded for owners, nothi
assert.strictEqual(r.previous_exposure, 'public_listed');
assert.strictEqual(r.exposure, 'restricted');
// The removal row and the outbox event are the same removal.
-- const ev = (await svc.outbox.all()).find(e => e.event_type === 'search.document.removed' && e.payload.id === 'inert1');
++ const ev = (await outboxEvents(svc.db)).find(e => e.event_type === 'search.document.removed' && e.payload.id === 'inert1');
assert.strictEqual(r.event_id, ev.event_id);
const ready = await request(svc.base, 'GET', '/api/ready');
assert.match(JSON.stringify(ready.body), /CLOUDFLARE_PURGE_TOKEN unset/);
ddiff --git a/test/webhook-inbox.test.js b/test/webhook-inbox.test.js
iindex b547119..0a23b49 100644
---- a/test/webhook-inbox.test.js
++++ b/test/webhook-inbox.test.js
@@@ -2,7 +2,7 @@
/** Events consumer: signatures, inbox dedupe, revision order across deliveries, tombstones, refusals. */
const assert = require('assert');
const { ids } = require('openvibe-contracts');
--const { boot, request, serviceToken, doc, indexEvent, deliver, suite } = require('./helpers');
++const { boot, request, serviceToken, doc, indexEvent, deliver, suite, outboxEvents } = require('./helpers');
const t = suite('webhook-inbox');
let svc;
@@@ -45,7 +45,7 @@ t('a delivered upsert is indexed; the same event again is a no-op', async () =>
const again = await deliver(svc.base, e, { seq: 2 });
assert.deepStrictEqual([again.status, again.body.duplicate], [200, true]);
assert.strictEqual((await search('nuword')).length, 1);
-- const events = (await svc.outbox.all()).filter(x => x.payload.id === d.id && x.event_type === 'search.document.indexed');
++ const events = (await outboxEvents(svc.db)).filter(x => x.payload.id === d.id && x.event_type === 'search.document.indexed');
assert.strictEqual(events.length, 1, 'one effect, one event');
});
@@@ -73,7 +73,7 @@ t('a deletion event tombstones and wins over upserts delivered after it', async
assert.strictEqual((await deliver(svc.base, indexEvent(d))).body.outcome, 'stale');
assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 5 }))).body.outcome, 'stale');
assert.strictEqual((await search('omicronword')).length, 0);
-- const removed = (await svc.outbox.all()).filter(x => x.event_type === 'search.document.removed' && x.payload.id === d.id);
++ const removed = (await outboxEvents(svc.db)).filter(x => x.event_type === 'search.document.removed' && x.payload.id === d.id);
assert.deepStrictEqual(removed.map(x => x.payload.reason), ['deleted']);
});
@@@ -157,7 +157,7 @@ t('a failure while applying leaves no receipt, so the redelivery applies exactly
svc.store.apply = original;
}
assert.strictEqual((await search('tauword')).length, 1);
-- assert.strictEqual((await svc.outbox.all()).filter(x => x.payload.id === d.id).length, 1);
++ assert.strictEqual((await outboxEvents(svc.db)).filter(x => x.payload.id === d.id).length, 1);
});
t('OpenVibe.Sources item documents (the first real producer) index for staff only', async () => {