openvibers/openvibe.help Commits
Help: article pages in OpenVibe.Search (search-feed, help.index_document.*) (#13)
Changes
@@@ -278,7 +278,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.127.0++- openvibe-contracts: v0.128.0++- openvibe-publishing: v1.4.0- openvibe-sdk: v0.36.0- openvibe-shared: v2.21.0<!-- versions:end -->@@@ -7,13 +7,14 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "5020","domain": "openvibe.help",-- "contracts": "openvibe-contracts v0.127.0",++ "contracts": "openvibe-contracts v0.128.0","sdk": "openvibe-sdk v0.36.0","shared": "openvibe-shared v2.21.0","packages": [-- "openvibe-contracts v0.127.0",++ "openvibe-contracts v0.128.0","openvibe-sdk v0.36.0",-- "openvibe-shared v2.21.0"++ "openvibe-shared v2.21.0",++ "openvibe-publishing v1.4.0"],"summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.","works": [nnew file mode 100644@@@ -0,0 +1,31 @@++-- phase: expand++-- OpenVibe.Help: article pages in OpenVibe.Search (server/search-index.js, openvibe-publishing/search-feed).++-- event_outbox holds the help.index_document.* events until the relay delivers them to OpenVibe.Events (written in++-- the same transaction as the revision that produced them); help_index_revisions is the index sequencer, the last++-- revision and content hash Search was sent for each page, so an unchanged page is never sent twice.++++CREATE TABLE IF NOT EXISTS event_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 event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;++++CREATE TABLE IF NOT EXISTS help_index_revisions (++ owner text COLLATE "C" NOT NULL,++ type text COLLATE "C" NOT NULL,++ id text COLLATE "C" NOT NULL,++ revision integer NOT NULL,++ hash text NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (owner, type, id)++);@@@ -15,7 +15,8 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0","pg": "^8.23.0"@@@ -871,9 +872,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.127.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",-- "integrity": "sha512-ySC+Qh8I3OlN5MULCqv0vfcAGubZQzWM0Fw7EOpnOo76vVnrkwx+m33aTxDum2yQqPkU8Ot5UtMuMwm1Cc1k3g==",++ "version": "0.128.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",++ "integrity": "sha512-r/WKk3NhdqxEsVxqsVPjg+HTD2So1amRx0BY6EOHhvmwjLuL9j5XJrNDZAfqf/4/P2xZCBzTLrtUqlWxC2EA6Q==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -886,6 +887,24 @@"node": ">=20"}},++ "node_modules/openvibe-publishing": {++ "version": "1.4.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",++ "integrity": "sha512-9vByyVqpvy2zVQI4PrJWk5QqCym/YQ/fXZDgrcloKGBqvukKrUn7fg4ZERiJHcgEq+rgDbxbU4rNflZhw62K/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "openvibe-sdk": ">=0.15.0",++ "openvibe-shared": ">=1.5.0"++ },++ "peerDependenciesMeta": {++ "openvibe-shared": {++ "optional": true++ }++ }++ },"node_modules/openvibe-sdk": {"version": "0.36.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",@@@ -2,7 +2,7 @@"name": "openvibe-help","version": "0.1.0","private": true,-- "description": "OpenVibe.Help — Answers, support and a way to reach people.",++ "description": "OpenVibe.Help \u2014 Answers, support and a way to reach people.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -22,7 +22,8 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0","pg": "^8.23.0"@@@ -26,6 +26,7 @@ const cache = require('openvibe-shared/cache-policy');const configLib = require('./config');const { openStore } = require('./db');const { createCatalog } = require('./catalog');++const { createSearchIndex } = require('./search-index');const { createNetworkSearch } = require('./search');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');@@@ -60,6 +61,8 @@ async function createApp(opts = {}) {const catalog = createCatalog({ now: s.now });const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });const ctx = { config, s, keys, sso, principal, catalog, search, log };++ // The articles in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.++ ctx.searchIndex = opts.searchIndex || createSearchIndex({ config, s, catalog, log });// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;@@@ -15,7 +15,7 @@ const { gracefulStop } = require('openvibe-sdk/service');function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {return gracefulStop({name: 'OpenVibe.Help', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.searchIndex.stop(), () => ctx.s.close(), ...extra],});}@@@ -28,6 +28,7 @@ async function start() {});server.keepAliveTimeout = 65_000;ctx.keys.client.start();++ ctx.searchIndex.start();// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without HELP_EVENTS_URL and// HELP_EVENTS_SECRET). The consumer itself is mounted in server/app.js.nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++++/**++ * Help's article pages in OpenVibe.Search (openvibe-publishing/search-feed): one search.index-document@1 per article++ * (/a/<domain>/<slug>, id <domain>:<slug>), sent as help.index_document.upserted|deleted through this service's events++ * outbox. An article is one question from a product manifest's FAQ with its answer, quoted verbatim from++ * OpenVibe.Contracts (server/catalog.js); the document names the manifest's package as provenance.++ *++ * The catalog is built at boot, so a sweep a minute after boot and every six hours is all it takes: a new Contracts++ * pin that adds, rewords or drops a question is a document, a new revision or a tombstone (help_index_revisions is++ * the sequencer), and a quiet boot sends nothing.++ *++ * const search = createSearchIndex({ config, s, catalog, log });++ * search.start(); search.sweep(); search.stop();++ */++const { createServiceOutbox } = require('openvibe-sdk/events');++const { createSearchFeed } = require('openvibe-publishing/search-feed');++++const EVENT_TYPES = ['help.index_document.upserted', 'help.index_document.deleted'];++const START_DELAY_MS = 60_000;++const INTERVAL_MS = 6 * 3_600_000;++++const articleId = (a) => `${a.site}:${a.slug}`;++++/** One catalog article → what its page shows, as search-feed's document description. */++function describe(article, catalog) {++ const site = catalog.get(article.site);++ return {++ id: articleId(article),++ title: article.question,++ summary: String(article.answer || '').slice(0, 300),++ body: [article.question, article.answer, article.siteName].filter(Boolean).join('\n'),++ facets: { site: article.site, kind: (site && site.kind) || null, state: article.siteState || null },++ authorship: { mode: 'human' },++ provenance: [{ service: 'help', type: 'manifest', id: article.site, label: catalog.source.name, url: catalog.source.url }],++ updatedAt: catalog.generatedAt,++ };++}++++function createSearchIndex({ config, s, catalog, log = console, outbox = null }) {++ const out = outbox || createServiceOutbox({++ db: s.db, source: 'help', eventsUrl: config.events.url || null, networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, log, eventTypes: EVENT_TYPES,++ });++ // The catalog in id order once: rows() pages through it like a table.++ const ordered = catalog.articles.map((a) => ({ ...a, id: articleId(a) })).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));++ const ids = new Set(ordered.map((a) => a.id));++ const feed = createSearchFeed({++ owner: 'help', db: s.db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,++ types: {++ article: {++ page: (a) => a.path,++ document: (a) => describe(a, catalog),++ rows: async (after, limit) => ordered.filter((a) => a.id > after).slice(0, limit),++ exists: async (list) => list.filter((id) => ids.has(id)),++ },++ },++ });++ const timers = [];++++ async function sweep() {++ const res = await feed.sweep();++ const a = res.article;++ if (a.sent || a.removed || a.failed) log.log(`[Search] articles: ${a.sent} sent, ${a.removed} removed, ${a.failed} failed of ${a.seen}`);++ if (out.kick) out.kick().catch(() => {});++ return res;++ }++ const quietly = () => { sweep().catch((err) => log.warn(`[Search] sweep failed: ${(err && err.message) || err}`)); };++++ function start() {++ if (timers.length) return false;++ out.start();++ const kick = setTimeout(quietly, START_DELAY_MS);++ if (kick.unref) kick.unref();++ const tick = setInterval(quietly, INTERVAL_MS);++ if (tick.unref) tick.unref();++ timers.push(kick, tick);++ return true;++ }++++ function stop() {++ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }++ out.stop();++ }++++ return { feed, outbox: out, describe: (a) => describe(a, catalog), sweep, start, stop, status: () => out.status() };++}++++module.exports = { createSearchIndex, describe, articleId, EVENT_TYPES };@@@ -69,6 +69,7 @@ async function boot(opts = {}) {base, network, config, ctx: built.ctx, get, signIn, dbDump,logs: () => captured.join('\n'),async close() {++ built.ctx.searchIndex.stop();await new Promise((r) => server.close(r));built.ctx.keys.client.stop();await testdb.close();nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * Help's articles in OpenVibe.Search (server/search-index.js): a sweep puts every catalog article in the outbox as one++ * help.index_document.upserted, exactly the contract, with the article's own page as its URL and the Contracts package++ * as provenance; a second sweep sends nothing; an article the catalog no longer has becomes a tombstone.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { createSearchIndex, articleId } = require('../server/search-index');++++(async () => {++ const t = await boot();++ const { s, catalog, searchIndex } = t.ctx;++ const outbox = async () => (await s.db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope);++ const valid = (env) => {++ assert.ok(contracts.validate('events.event-envelope@1', env).valid, 'envelope');++ const p = contracts.validate(env.event_type, env.payload);++ assert.ok(p.valid, `${env.event_type}: ${JSON.stringify(p.errors)} ${JSON.stringify(env.payload).slice(0, 200)}`);++ assert.strictEqual(env.source, 'help');++ return env;++ };++++ try {++ await check('a sweep sends every article once, as the contract says, and the page it names answers', async () => {++ assert.ok(catalog.articles.length > 10, 'the catalog has articles');++ const res = await searchIndex.sweep();++ assert.deepStrictEqual(res.article, { seen: catalog.articles.length, sent: catalog.articles.length, removed: 0, failed: 0 });++ const envs = (await outbox()).map(valid);++ assert.strictEqual(envs.length, catalog.articles.length);++ const a = catalog.articles[0];++ const doc = envs.find((e) => e.payload.id === articleId(a)).payload;++ assert.strictEqual(doc.title, a.question);++ assert.strictEqual(doc.canonical_url, `https://openvibe.help${a.path}`);++ assert.ok(doc.body.includes(a.answer.slice(0, 40)), 'the answer, verbatim');++ assert.strictEqual(doc.facets.site, a.site);++ assert.strictEqual(doc.provenance[0].service, 'help');++ assert.strictEqual(doc.provenance[0].type, 'manifest');++ const page = await t.get(a.path);++ assert.strictEqual(page.status, 200, a.path);++ });++++ await check('a second sweep sends nothing', async () => {++ const before = (await outbox()).length;++ assert.strictEqual((await searchIndex.sweep()).article.sent, 0);++ assert.strictEqual((await outbox()).length, before);++ });++++ await check('an article the catalog dropped becomes a tombstone, once', async () => {++ const dropped = catalog.articles[1];++ const smaller = { ...catalog, articles: catalog.articles.filter((x) => x !== dropped) };++ const other = createSearchIndex({ config: t.config, s, catalog: smaller, outbox: searchIndex.outbox, log: { log() {}, warn() {} } });++ const res = await other.sweep();++ assert.strictEqual(res.article.removed, 1);++ const env = valid((await outbox()).pop());++ assert.strictEqual(env.event_type, 'help.index_document.deleted');++ assert.deepStrictEqual(env.payload, { type: 'article', id: articleId(dropped), revision: 2 });++ assert.strictEqual((await other.sweep()).article.removed, 0);++ });++ } finally {++ await done(t);++ }++})();
@@ -278,7 +278,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -278,7 +278,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.127.0
+- openvibe-contracts: v0.128.0
+- openvibe-publishing: v1.4.0
- openvibe-sdk: v0.36.0
- openvibe-sdk: v0.36.0
- openvibe-shared: v2.21.0
- openvibe-shared: v2.21.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,13 +7,14 @@
@@ -7,13 +7,14 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5020",
"port": "5020",
"domain": "openvibe.help",
"domain": "openvibe.help",
- "contracts": "openvibe-contracts v0.127.0",
+ "contracts": "openvibe-contracts v0.128.0",
"sdk": "openvibe-sdk v0.36.0",
"sdk": "openvibe-sdk v0.36.0",
"shared": "openvibe-shared v2.21.0",
"shared": "openvibe-shared v2.21.0",
"packages": [
"packages": [
- "openvibe-contracts v0.127.0",
+ "openvibe-contracts v0.128.0",
"openvibe-sdk v0.36.0",
"openvibe-sdk v0.36.0",
- "openvibe-shared v2.21.0"
+ "openvibe-shared v2.21.0",
+ "openvibe-publishing v1.4.0"
],
],
"summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.",
"summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.",
"works": [
"works": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+-- phase: expand
+-- OpenVibe.Help: article pages in OpenVibe.Search (server/search-index.js, openvibe-publishing/search-feed).
+-- event_outbox holds the help.index_document.* events until the relay delivers them to OpenVibe.Events (written in
+-- the same transaction as the revision that produced them); help_index_revisions is the index sequencer, the last
+-- revision and content hash Search was sent for each page, so an unchanged page is never sent twice.
+
+CREATE TABLE IF NOT EXISTS event_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 event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS help_index_revisions (
+ owner text COLLATE "C" NOT NULL,
+ type text COLLATE "C" NOT NULL,
+ id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL,
+ hash text NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (owner, type, id)
+);
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -871,9 +872,9 @@
@@ -871,9 +872,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.127.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
- "integrity": "sha512-ySC+Qh8I3OlN5MULCqv0vfcAGubZQzWM0Fw7EOpnOo76vVnrkwx+m33aTxDum2yQqPkU8Ot5UtMuMwm1Cc1k3g==",
+ "version": "0.128.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",
+ "integrity": "sha512-r/WKk3NhdqxEsVxqsVPjg+HTD2So1amRx0BY6EOHhvmwjLuL9j5XJrNDZAfqf/4/P2xZCBzTLrtUqlWxC2EA6Q==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -886,6 +887,24 @@
@@ -886,6 +887,24 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-publishing": {
+ "version": "1.4.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
+ "integrity": "sha512-9vByyVqpvy2zVQI4PrJWk5QqCym/YQ/fXZDgrcloKGBqvukKrUn7fg4ZERiJHcgEq+rgDbxbU4rNflZhw62K/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "openvibe-sdk": ">=0.15.0",
+ "openvibe-shared": ">=1.5.0"
+ },
+ "peerDependenciesMeta": {
+ "openvibe-shared": {
+ "optional": true
+ }
+ }
+ },
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
"version": "0.36.0",
"version": "0.36.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-help",
"name": "openvibe-help",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Help — Answers, support and a way to reach people.",
+ "description": "OpenVibe.Help \u2014 Answers, support and a way to reach people.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -22,7 +22,8 @@
@@ -22,7 +22,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -26,6 +26,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -26,6 +26,7 @@ const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
const { createCatalog } = require('./catalog');
const { createCatalog } = require('./catalog');
+const { createSearchIndex } = require('./search-index');
const { createNetworkSearch } = require('./search');
const { createNetworkSearch } = require('./search');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
@@ -60,6 +61,8 @@ async function createApp(opts = {}) {
@@ -60,6 +61,8 @@ async function createApp(opts = {}) {
const catalog = createCatalog({ now: s.now });
const catalog = createCatalog({ now: s.now });
const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });
const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });
const ctx = { config, s, keys, sso, principal, catalog, search, log };
const ctx = { config, s, keys, sso, principal, catalog, search, log };
+ // The articles in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
+ ctx.searchIndex = opts.searchIndex || createSearchIndex({ config, s, catalog, log });
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@ -15,7 +15,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
@@ -15,7 +15,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'OpenVibe.Help', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
name: 'OpenVibe.Help', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],
+ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.searchIndex.stop(), () => ctx.s.close(), ...extra],
});
});
}
}
@@ -28,6 +28,7 @@ async function start() {
@@ -28,6 +28,7 @@ async function start() {
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
ctx.keys.client.start();
+ ctx.searchIndex.start();
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without HELP_EVENTS_URL and
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without HELP_EVENTS_URL and
// HELP_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
// HELP_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+
+/**
+ * Help's article pages in OpenVibe.Search (openvibe-publishing/search-feed): one search.index-document@1 per article
+ * (/a/<domain>/<slug>, id <domain>:<slug>), sent as help.index_document.upserted|deleted through this service's events
+ * outbox. An article is one question from a product manifest's FAQ with its answer, quoted verbatim from
+ * OpenVibe.Contracts (server/catalog.js); the document names the manifest's package as provenance.
+ *
+ * The catalog is built at boot, so a sweep a minute after boot and every six hours is all it takes: a new Contracts
+ * pin that adds, rewords or drops a question is a document, a new revision or a tombstone (help_index_revisions is
+ * the sequencer), and a quiet boot sends nothing.
+ *
+ * const search = createSearchIndex({ config, s, catalog, log });
+ * search.start(); search.sweep(); search.stop();
+ */
+const { createServiceOutbox } = require('openvibe-sdk/events');
+const { createSearchFeed } = require('openvibe-publishing/search-feed');
+
+const EVENT_TYPES = ['help.index_document.upserted', 'help.index_document.deleted'];
+const START_DELAY_MS = 60_000;
+const INTERVAL_MS = 6 * 3_600_000;
+
+const articleId = (a) => `${a.site}:${a.slug}`;
+
+/** One catalog article → what its page shows, as search-feed's document description. */
+function describe(article, catalog) {
+ const site = catalog.get(article.site);
+ return {
+ id: articleId(article),
+ title: article.question,
+ summary: String(article.answer || '').slice(0, 300),
+ body: [article.question, article.answer, article.siteName].filter(Boolean).join('\n'),
+ facets: { site: article.site, kind: (site && site.kind) || null, state: article.siteState || null },
+ authorship: { mode: 'human' },
+ provenance: [{ service: 'help', type: 'manifest', id: article.site, label: catalog.source.name, url: catalog.source.url }],
+ updatedAt: catalog.generatedAt,
+ };
+}
+
+function createSearchIndex({ config, s, catalog, log = console, outbox = null }) {
+ const out = outbox || createServiceOutbox({
+ db: s.db, source: 'help', eventsUrl: config.events.url || null, networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, log, eventTypes: EVENT_TYPES,
+ });
+ // The catalog in id order once: rows() pages through it like a table.
+ const ordered = catalog.articles.map((a) => ({ ...a, id: articleId(a) })).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+ const ids = new Set(ordered.map((a) => a.id));
+ const feed = createSearchFeed({
+ owner: 'help', db: s.db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,
+ types: {
+ article: {
+ page: (a) => a.path,
+ document: (a) => describe(a, catalog),
+ rows: async (after, limit) => ordered.filter((a) => a.id > after).slice(0, limit),
+ exists: async (list) => list.filter((id) => ids.has(id)),
+ },
+ },
+ });
+ const timers = [];
+
+ async function sweep() {
+ const res = await feed.sweep();
+ const a = res.article;
+ if (a.sent || a.removed || a.failed) log.log(`[Search] articles: ${a.sent} sent, ${a.removed} removed, ${a.failed} failed of ${a.seen}`);
+ if (out.kick) out.kick().catch(() => {});
+ return res;
+ }
+ const quietly = () => { sweep().catch((err) => log.warn(`[Search] sweep failed: ${(err && err.message) || err}`)); };
+
+ function start() {
+ if (timers.length) return false;
+ out.start();
+ const kick = setTimeout(quietly, START_DELAY_MS);
+ if (kick.unref) kick.unref();
+ const tick = setInterval(quietly, INTERVAL_MS);
+ if (tick.unref) tick.unref();
+ timers.push(kick, tick);
+ return true;
+ }
+
+ function stop() {
+ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }
+ out.stop();
+ }
+
+ return { feed, outbox: out, describe: (a) => describe(a, catalog), sweep, start, stop, status: () => out.status() };
+}
+
+module.exports = { createSearchIndex, describe, articleId, EVENT_TYPES };
@@ -69,6 +69,7 @@ async function boot(opts = {}) {
@@ -69,6 +69,7 @@ async function boot(opts = {}) {
base, network, config, ctx: built.ctx, get, signIn, dbDump,
base, network, config, ctx: built.ctx, get, signIn, dbDump,
logs: () => captured.join('\n'),
logs: () => captured.join('\n'),
async close() {
async close() {
+ built.ctx.searchIndex.stop();
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
built.ctx.keys.client.stop();
built.ctx.keys.client.stop();
await testdb.close();
await testdb.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * Help's articles in OpenVibe.Search (server/search-index.js): a sweep puts every catalog article in the outbox as one
+ * help.index_document.upserted, exactly the contract, with the article's own page as its URL and the Contracts package
+ * as provenance; a second sweep sends nothing; an article the catalog no longer has becomes a tombstone.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { createSearchIndex, articleId } = require('../server/search-index');
+
+(async () => {
+ const t = await boot();
+ const { s, catalog, searchIndex } = t.ctx;
+ const outbox = async () => (await s.db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope);
+ const valid = (env) => {
+ assert.ok(contracts.validate('events.event-envelope@1', env).valid, 'envelope');
+ const p = contracts.validate(env.event_type, env.payload);
+ assert.ok(p.valid, `${env.event_type}: ${JSON.stringify(p.errors)} ${JSON.stringify(env.payload).slice(0, 200)}`);
+ assert.strictEqual(env.source, 'help');
+ return env;
+ };
+
+ try {
+ await check('a sweep sends every article once, as the contract says, and the page it names answers', async () => {
+ assert.ok(catalog.articles.length > 10, 'the catalog has articles');
+ const res = await searchIndex.sweep();
+ assert.deepStrictEqual(res.article, { seen: catalog.articles.length, sent: catalog.articles.length, removed: 0, failed: 0 });
+ const envs = (await outbox()).map(valid);
+ assert.strictEqual(envs.length, catalog.articles.length);
+ const a = catalog.articles[0];
+ const doc = envs.find((e) => e.payload.id === articleId(a)).payload;
+ assert.strictEqual(doc.title, a.question);
+ assert.strictEqual(doc.canonical_url, `https://openvibe.help${a.path}`);
+ assert.ok(doc.body.includes(a.answer.slice(0, 40)), 'the answer, verbatim');
+ assert.strictEqual(doc.facets.site, a.site);
+ assert.strictEqual(doc.provenance[0].service, 'help');
+ assert.strictEqual(doc.provenance[0].type, 'manifest');
+ const page = await t.get(a.path);
+ assert.strictEqual(page.status, 200, a.path);
+ });
+
+ await check('a second sweep sends nothing', async () => {
+ const before = (await outbox()).length;
+ assert.strictEqual((await searchIndex.sweep()).article.sent, 0);
+ assert.strictEqual((await outbox()).length, before);
+ });
+
+ await check('an article the catalog dropped becomes a tombstone, once', async () => {
+ const dropped = catalog.articles[1];
+ const smaller = { ...catalog, articles: catalog.articles.filter((x) => x !== dropped) };
+ const other = createSearchIndex({ config: t.config, s, catalog: smaller, outbox: searchIndex.outbox, log: { log() {}, warn() {} } });
+ const res = await other.sweep();
+ assert.strictEqual(res.article.removed, 1);
+ const env = valid((await outbox()).pop());
+ assert.strictEqual(env.event_type, 'help.index_document.deleted');
+ assert.deepStrictEqual(env.payload, { type: 'article', id: articleId(dropped), revision: 2 });
+ assert.strictEqual((await other.sweep()).article.removed, 0);
+ });
+ } finally {
+ await done(t);
+ }
+})();