openvibers/openvibe.search Commits
Outbox: openvibe-sdk/events instead of the hand-rolled copy (#37)
Changes
@@@ -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 thedatabase (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 webhookis on. `GET /metrics` (openvibe-shared/metrics) answers direct loopback callers only: golden signalsby route template, `search_documents{exposure}`, `search_documents_indexed{index}` and the outboxbacklog.@@@ -166,7 +167,8 @@ Every change that lowers a document's exposure (deleted, drafted, unpublished, mprivate/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 sitemapspurge); 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 andvisibility-change propagation, including the CDN purge of removed public URLs@@@ -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"nnew file mode 100644@@@ -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.@@@ -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);});@@@ -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@@@ -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@@@ -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),ddeleted file mode 100644@@@ -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 };@@@ -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(), fetchconfig, 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(), fetchasync 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(), fetchif (!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) {@@@ -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, purgereturn {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'],@@@ -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));}}@@@ -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.dawait 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')), []);@@@ -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,};nnew file mode 100644@@@ -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();@@@ -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();@@@ -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, nothiassert.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/);@@@ -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', asyncassert.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 exactlysvc.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 () => {
@@ -37,7 +37,8 @@ API and the Events webhook are loopback-only, and owners and Events call `127.0.
@@ -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
`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
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
(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
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
by route template, `search_documents{exposure}`, `search_documents_indexed{index}` and the outbox
backlog.
backlog.
@@ -166,7 +167,8 @@ Every change that lowers a document's exposure (deleted, drafted, unpublished, m
@@ -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
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
`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
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
### Removals, caches and the CDN
@@ -192,8 +194,9 @@ the `search.document.removed` event it records:
@@ -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/))
- 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`,
- `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))
[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
- 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
visibility-change propagation, including the CDN purge of removed public URLs
@@ -34,6 +34,7 @@
@@ -34,6 +34,7 @@
"server-rendered search page",
"server-rendered search page",
"owner removal feed",
"owner removal feed",
"Cloudflare purge queue (inert until CLOUDFLARE_PURGE_TOKEN is set)",
"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)"
"per-actor limits (openvibe-sdk/limits)"
],
],
"updated": "2026-10-10"
"updated": "2026-10-10"
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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.
@@ -42,7 +42,7 @@ function outcomeResponse(res, ctx, r) {
@@ -42,7 +42,7 @@ function outcomeResponse(res, ctx, r) {
return res.json(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 router = express.Router();
const guard = auth.requireCap(CAPS.write);
const guard = auth.requireCap(CAPS.write);
// Per-actor limits (server/actor-limits.js), by service principal; reconciliation reads take the
// 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 }) {
@@ -78,7 +78,7 @@ function documentsRouter({ store, auth, db, relay, purges, limits }) {
const v = validate(doc);
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 });
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 });
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);
return outcomeResponse(res, ctx, r);
});
});
@@ -91,7 +91,7 @@ function documentsRouter({ store, auth, db, relay, purges, limits }) {
@@ -91,7 +91,7 @@ function documentsRouter({ store, auth, db, relay, purges, limits }) {
revision = Number(req.query.revision);
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 });
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);
return outcomeResponse(res, ctx, r);
});
});
@@ -119,7 +119,7 @@ function documentFromEvent(event, owners) {
@@ -119,7 +119,7 @@ function documentFromEvent(event, owners) {
return { doc };
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 router = express.Router();
const inbox = createInbox(db, { now });
const inbox = createInbox(db, { now });
@@ -160,7 +160,7 @@ function webhookRouter({ config, db, store, relay, log = console, 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 });
const out = await store.apply(parsed.doc, { via: 'event', eventId: event.event_id, traceId });
return out.outcome;
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 });
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
} catch (err) { return next(err); } }); // Express 4 does not catch an async handler's rejection
@@ -15,7 +15,7 @@ const { createLimits } = require('./actor-limits');
@@ -15,7 +15,7 @@ const { createLimits } = require('./actor-limits');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
const pkg = require('../package.json');
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -49,7 +49,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
@@ -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).
// 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('/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'] }));
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
@@ -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)
// 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).
// 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);
app.get('/api/ready', readiness.handler);
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
@@ -68,7 +68,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
@@ -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,
// 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.
// health, ready, release.json and metrics above are never limited.
const limits = createLimits({ config, now: now || (() => Date.now()), registry: metrics.registry, log });
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(queryRouter({ config, store, engine, auth, searcher, limits }));
app.use(savedRouter({ config, saved, searcher, auth, 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
// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
@@ -75,7 +75,7 @@ function load(env = process.env) {
@@ -75,7 +75,7 @@ function load(env = process.env) {
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
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: {
events: {
url: strip(env.EVENTS_URL || ''),
url: strip(env.EVENTS_URL || ''),
relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
deleted file mode 100644
deleted file mode 100644
@@ -1,163 +0,0 @@
@@ -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 };
@@ -11,7 +11,8 @@ const { load } = require('./config');
@@ -11,7 +11,8 @@ const { load } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
const { createEngine } = require('./engine/pg');
const { createEngine } = require('./engine/pg');
const { createStore } = require('./store');
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 { jwksClient } = require('openvibe-sdk/auth');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
const { createAuth } = require('./auth');
const { createAuth } = require('./auth');
@@ -22,7 +23,7 @@ const { createNetworkPush } = require('./network-push');
@@ -22,7 +23,7 @@ const { createNetworkPush } = require('./network-push');
const { createSavedNotifier } = require('./saved-notifier');
const { createSavedNotifier } = require('./saved-notifier');
const { createApp } = require('./app');
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();
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const db = givenDb || await openDb(config, { log });
@@ -30,16 +31,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -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.
// The index is derived from documents: rebuilt at boot when it disagrees with the documents table.
const rebuilt = await engine.reconcile();
const rebuilt = await engine.reconcile();
if (rebuilt.rebuilt) log.log(`[search] full-text index rebuilt: ${rebuilt.rebuilt} document(s)`);
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 purges = createPurgeQueue(db, { config: config.purge, now });
const purger = createPurger({ db, config: config.purge, fetchImpl, log, now });
const purger = createPurger({ db, config: config.purge, fetchImpl, log, now });
const saved = createSavedSearches(db, { maxPerSubject: config.savedSearches.maxPerSubject, now });
const saved = createSavedSearches(db, { maxPerSubject: config.savedSearches.maxPerSubject, now });
const store = createStore({ db, engine, outbox, purges, 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
// 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.
// 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`;
const jwksUrl = `${config.networkInternalUrl}/api/.well-known/jwks`;
@@ -51,16 +53,16 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -51,16 +53,16 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
config, saved, searcher, now, log,
config, saved, searcher, now, log,
network: networkPush || createNetworkPush({ config, fetchImpl }),
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 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.
// the keys once below); a real boot refreshes on an unref'd timer. keyLoaded settles the first load.
if (config.nodeEnv !== 'test') jwks.start();
if (config.nodeEnv !== 'test') jwks.start();
const keyLoaded = jwks.keys().catch(() => null);
const keyLoaded = jwks.keys().catch(() => null);
- relay.start();
+ outbox.start();
purger.start();
purger.start();
notifier.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?.();
pruneTimer.unref?.();
let server = null;
let server = null;
@@ -75,7 +77,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -75,7 +77,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
async function close() {
async function close() {
clearInterval(pruneTimer);
clearInterval(pruneTimer);
jwks.stop();
jwks.stop();
- await relay.stop();
+ await outbox.stop();
await purger.stop();
await purger.stop();
await notifier.stop();
await notifier.stop();
if (server) {
if (server) {
@@ -85,7 +87,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -85,7 +87,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
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) {
if (require.main === module) {
@@ -35,7 +35,7 @@ function readers(db, engine) {
@@ -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);
const read = readers(db, engine);
return createReadiness({
return createReadiness({
service: 'search',
service: 'search',
@@ -83,7 +83,7 @@ function createSearchReadiness({ db, config, engine, store, outbox, relay, purge
@@ -83,7 +83,7 @@ function createSearchReadiness({ db, config, engine, store, outbox, relay, purge
return {
return {
engine: engine.name,
engine: engine.name,
documents: dbOk ? await store.counts() : null,
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)',
webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',
purge: dbOk && purges ? {
purge: dbOk && purges ? {
cdn: purges.cdnOn() ? (purger && purger.running() ? 'cloudflare' : 'cloudflare (stopped)') : 'off (CLOUDFLARE_PURGE_TOKEN unset)',
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 }) {
@@ -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'],
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 })),
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) {
if (purges) {
registry.gauge({
registry.gauge({
name: 'search_cdn_purges', help: 'Cloudflare cache purges of removed documents, by state', labelNames: ['state'],
name: 'search_cdn_purges', help: 'Cloudflare cache purges of removed documents, by state', labelNames: ['state'],
@@ -85,6 +85,16 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
@@ -85,6 +85,16 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
return 'noindex';
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) {
async function announce(prevRow, prevDoc, doc, exposure, traceId) {
const prevExposure = prevRow ? prevRow.exposure : EXPOSURE.none;
const prevExposure = prevRow ? prevRow.exposure : EXPOSURE.none;
const subject = { type: 'document', id: `${doc.owner}/${doc.type}/${doc.id}`, revision: doc.revision };
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()
@@ -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.
// Only a URL that was public already: caches and sitemaps purge it.
canonical_url: wasPublic ? prevRow.canonical_url : null,
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).
// 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 (purges) await purges.record(env.event_id, payload);
}
}
if (!doc.deleted && exposure > EXPOSURE.none) {
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,
owner: doc.owner, type: doc.type, id: doc.id, revision: doc.revision,
exposure: EXPOSURE_NAME[exposure],
exposure: EXPOSURE_NAME[exposure],
reindexed: Boolean(prevRow),
reindexed: Boolean(prevRow),
canonical_url: exposure >= EXPOSURE.public_unlisted ? doc.canonical_url : null,
canonical_url: exposure >= EXPOSURE.public_unlisted ? doc.canonical_url : null,
- },
- });
+ }, traceId));
}
}
}
}
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*/
*/
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
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');
const t = suite('acl-leak');
let svc;
let svc;
@@ -215,7 +215,7 @@ t('a visibility change or deletion leaves results at once and announces search.d
@@ -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 });
await request(svc.base, 'DELETE', '/api/v1/documents/wiki/page/flip?revision=3', { token: WIKI });
assert.deepStrictEqual(idsOf(await q('q=iotaword', asUser(alice))), []);
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.strictEqual(removed.length, 2);
assert.deepStrictEqual(removed.map(e => [e.payload.reason, e.payload.previous_exposure, e.payload.exposure]), [
assert.deepStrictEqual(removed.map(e => [e.payload.reason, e.payload.previous_exposure, e.payload.exposure]), [
['visibility_changed', 'public_listed', 'restricted'],
['visibility_changed', 'public_listed', 'restricted'],
@@ -233,7 +233,7 @@ t('publishing → draft and public → noindex also announce removal', async ()
@@ -233,7 +233,7 @@ t('publishing → draft and public → noindex also announce removal', async ()
const d2 = doc({ id: 'noidx', title: 'Lambdaword', revision: 1 });
const d2 = doc({ id: 'noidx', title: 'Lambdaword', revision: 1 });
await put(d2);
await put(d2);
await put({ ...d2, revision: 2, indexability: { decision: 'noindex', reasons: ['duplicate_without_canonical'] } });
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(reasons, ['unpub:not_published', 'noidx:noindex']);
assert.deepStrictEqual(idsOf(await q('q=kappaword')), []);
assert.deepStrictEqual(idsOf(await q('q=kappaword')), []);
assert.deepStrictEqual(idsOf(await q('q=lambdaword')), []);
assert.deepStrictEqual(idsOf(await q('q=lambdaword')), []);
@@ -43,7 +43,7 @@ function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openv
@@ -43,7 +43,7 @@ function userToken({ subjectId = ids.newId('user'), role = 'user', aud = ['openv
}, key);
}, key);
}
}
-async function boot({ env = {}, fetchImpl, jwksFetch, tokenClient, networkPush, now } = {}) {
+async function boot({ env = {}, fetchImpl, jwksFetch, networkPush, now } = {}) {
const config = load({
const config = load({
NODE_ENV: 'test',
NODE_ENV: 'test',
PORT: '0',
PORT: '0',
@@ -55,7 +55,7 @@ async function boot({ env = {}, fetchImpl, jwksFetch, tokenClient, networkPush,
@@ -55,7 +55,7 @@ async function boot({ env = {}, fetchImpl, jwksFetch, tokenClient, networkPush,
const inner = fetchImpl || globalThis.fetch;
const inner = fetchImpl || globalThis.fetch;
const jwks = jwksFetch || stubJwks;
const jwks = jwksFetch || stubJwks;
const fetchImplBoth = async (url, opts) => String(url).includes('/api/.well-known/jwks') ? jwks(url, opts) : inner(url, opts);
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}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, async stop() { await h.close(); await testdb.close(); } };
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 } = {})
@@ -71,6 +71,12 @@ async function request(base, method, p, { token, body, headers = {}, raw } = {})
return { status: res.status, body: json, text, headers: res.headers };
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. */
/** A valid, public, published, indexable document. Override anything. */
function doc(overrides = {}) {
function doc(overrides = {}) {
return {
return {
@@ -143,5 +149,5 @@ function suite(name) {
@@ -143,5 +149,5 @@ function suite(name) {
module.exports = {
module.exports = {
ISSUER, WEBHOOK_SECRET, privateKey, publicKey, silent, serviceToken, userToken, boot, request,
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,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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();
@@ -1,108 +1,106 @@
@@ -1,108 +1,106 @@
'use strict';
'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 assert = require('assert');
const http = require('http');
const http = require('http');
const { validate } = require('openvibe-contracts');
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 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) {
async function stubEvents(respond = () => 201) {
const calls = [];
const calls = [];
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const chunks = [];
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('data', c => chunks.push(c));
req.on('end', () => {
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 });
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));
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();
const svc = await boot();
try {
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 });
- 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 });
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']);
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.id, `wiki/page/${d.id}`);
assert.strictEqual(all[1].subject.revision, 2);
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 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 {
try {
const d = doc();
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 });
await request(svc.base, 'DELETE', `/api/v1/documents/wiki/page/${d.id}?revision=2`, { token: WIKI });
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'));
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';
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 {
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/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 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';
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();
const svc = await boot();
try {
try {
const r = await request(svc.base, 'GET', '/api/ready');
const r = await request(svc.base, 'GET', '/api/ready');
assert.strictEqual(r.status, 200);
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');
assert.strictEqual(r.body.webhook, 'on');
- } finally {
- await svc.stop();
- }
+ } finally { await svc.stop(); }
});
});
t.run();
t.run();
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* of formerly public URLs against a mocked Cloudflare API. No test talks to Cloudflare.
* of formerly public URLs against a mocked Cloudflare API. No test talks to Cloudflare.
*/
*/
const assert = require('assert');
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 { load } = require('../server/config');
const { zoneFor, purgeUrls, MAX_ATTEMPTS } = require('../server/purge');
const { zoneFor, purgeUrls, MAX_ATTEMPTS } = require('../server/purge');
@@ -71,7 +71,7 @@ t('inert without CLOUDFLARE_PURGE_TOKEN: removals are recorded for owners, nothi
@@ -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.previous_exposure, 'public_listed');
assert.strictEqual(r.exposure, 'restricted');
assert.strictEqual(r.exposure, 'restricted');
// The removal row and the outbox event are the same removal.
// 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);
assert.strictEqual(r.event_id, ev.event_id);
const ready = await request(svc.base, 'GET', '/api/ready');
const ready = await request(svc.base, 'GET', '/api/ready');
assert.match(JSON.stringify(ready.body), /CLOUDFLARE_PURGE_TOKEN unset/);
assert.match(JSON.stringify(ready.body), /CLOUDFLARE_PURGE_TOKEN unset/);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/** Events consumer: signatures, inbox dedupe, revision order across deliveries, tombstones, refusals. */
/** Events consumer: signatures, inbox dedupe, revision order across deliveries, tombstones, refusals. */
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
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');
const t = suite('webhook-inbox');
let svc;
let svc;
@@ -45,7 +45,7 @@ t('a delivered upsert is indexed; the same event again is a no-op', async () =>
@@ -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 });
const again = await deliver(svc.base, e, { seq: 2 });
assert.deepStrictEqual([again.status, again.body.duplicate], [200, true]);
assert.deepStrictEqual([again.status, again.body.duplicate], [200, true]);
assert.strictEqual((await search('nuword')).length, 1);
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');
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
@@ -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))).body.outcome, 'stale');
assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 5 }))).body.outcome, 'stale');
assert.strictEqual((await deliver(svc.base, indexEvent({ ...d, revision: 5 }))).body.outcome, 'stale');
assert.strictEqual((await search('omicronword')).length, 0);
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']);
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
@@ -157,7 +157,7 @@ t('a failure while applying leaves no receipt, so the redelivery applies exactly
svc.store.apply = original;
svc.store.apply = original;
}
}
assert.strictEqual((await search('tauword')).length, 1);
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 () => {
t('OpenVibe.Sources item documents (the first real producer) index for staff only', async () => {