openvibers/openvibe.shoes Commits
Shoe models in OpenVibe.Search (search-feed, contracts 0.273.0) (#39)
Changes
@@@ -159,7 +159,8 @@ section says so plainly when no store has cut a price yet.- No affiliate links, no sponsored order, no Marketplace scraping.<!-- versions:start -->--- openvibe-contracts: v0.265.0++- openvibe-contracts: v0.273.0++- openvibe-publishing: v1.4.1- openvibe-sdk: v0.81.0- openvibe-shared: v4.103.0<!-- versions:end -->@@@ -7,11 +7,12 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "5180","domain": "open.shoes",-- "contracts": "openvibe-contracts v0.265.0",++ "contracts": "openvibe-contracts v0.273.0","sdk": "openvibe-sdk v0.81.0","shared": "openvibe-shared v4.103.0","packages": [-- "openvibe-contracts v0.265.0",++ "openvibe-contracts v0.273.0",++ "openvibe-publishing v1.4.1","openvibe-sdk v0.81.0","openvibe-shared v4.103.0"],nnew file mode 100644@@@ -0,0 +1,31 @@++-- phase: expand++-- OpenVibe.Shoes: shoe model pages in OpenVibe.Search (server/search-index.js, openvibe-publishing/search-feed).++-- event_outbox holds the shoes.index_document.* events until the relay delivers them to OpenVibe.Events (written in++-- the same transaction as the revision that produced them); shoes_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 shoes_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.265.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.273.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0","pg": "^8.23.1"@@@ -1150,9 +1151,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.265.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",-- "integrity": "sha512-0KnKj1Rig6acZcv4U2uwaM2msarGUY36pNqFbqmKdLeOPPozf7haEDTam27A/WzSU9ii2yEZ6SnuusPPQoGZaA==",++ "version": "0.273.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.273.0",++ "integrity": "sha512-NgcTWnKG1UwHZOLYlJLo8usPWxeld7Cnh2doFDfjUvL6duKqru7aO9hKjM0fYFROWvSHKaURLYhQLQOpJEhdog==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1165,6 +1166,24 @@"node": ">=20"}},++ "node_modules/openvibe-publishing": {++ "version": "1.4.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",++ "integrity": "sha512-YjrcxCnXbpDQwyYpgsRcDj0+ckqZBm/ErEmQjvf0mCFMImwErG+lzZKXnXWb6eWOPxXEVj9YYuOgZiWVL41Vfg==",++ "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.81.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",@@@ -23,7 +23,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.265.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.273.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0","pg": "^8.23.1"@@@ -35,6 +35,7 @@ const { assetVersion, setRelease } = require('./render/layout');const { createCatalogue } = require('./catalogue');const { createUpstreams } = require('./upstreams');const { createJobs } = require('./jobs');++const { createSearchIndex } = require('./search-index');const accountDataLib = require('./identity/account-data');const { createNetworkSender } = require('openvibe-sdk/account-data');@@@ -60,6 +61,8 @@ async function createApp(opts = {}) {const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });const jobs = createJobs({ config, catalogue, upstreams, log });++ // The model pages in OpenVibe.Search (started by server/index.js, never under SHOES_DRILL).++ const searchIndex = createSearchIndex({ config, s, log });// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own// client-credentials token; a test injects a stand-in through opts.accountSend.@@@ -68,7 +71,7 @@ async function createApp(opts = {}) {? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });-- const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, accountData, accountSend, log };++ const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, searchIndex, accountData, accountSend, log };const app = express();app.disable('x-powered-by');@@@ -20,6 +20,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }() => { for (const t of timers) clearInterval(t); },() => ctx.keys.client.stop(),() => ctx.jobs.stop(),++ () => { if (ctx.searchIndex) ctx.searchIndex.stop(); },async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },() => ctx.s.close(),...extra,@@@ -45,6 +46,8 @@ async function start() {// Jobs. Each refuses to start under SHOES_DRILL; tests call their runOnce().ctx.jobs.start();++ // The model pages in OpenVibe.Search: a sweep a minute after boot and every six hours (off under SHOES_DRILL).++ ctx.searchIndex.start();// The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the// delivery secret and the client secret. The consumer itself is mounted in server/app.js.nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * open.shoes' model pages in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.273.0): one++ * search.index-document@1 per shoe model (/shoe/<slug>, the slug as the id), sent as shoes.index_document.upserted|deleted++ * through this service's events outbox. The document is what the page says: the model's name and brand, the lowest++ * all-in price seen today (in dollars, the way the page shows it) and how many stores sell it. A network search for a++ * shoe found deals and coupons but never the one page with every store's price (2026-11-02).++ *++ * A model with no live offer is a thin page, kept in Search but noindex; a model that left the catalogue gets a++ * tombstone (exists()). The catalogue is small (199 models on 2026-11-02) and its prices move through the day, so a++ * sweep a minute after boot and every six hours is enough: the sequencer (shoes_index_revisions) gives an unchanged++ * document its old revision, and a quiet sweep sends nothing. Off under SHOES_DRILL.++ */++const { createServiceOutbox } = require('openvibe-sdk/events');++const { createSearchFeed } = require('openvibe-publishing/search-feed');++++const EVENT_TYPES = ['shoes.index_document.upserted', 'shoes.index_document.deleted'];++const START_DELAY_MS = 60_000;++const INTERVAL_MS = 6 * 3_600_000;++++const dollars = (n) => (n == null || !Number.isFinite(Number(n)) ? null : `$${Math.round(Number(n)).toLocaleString('en-US')}`);++const iso = (t) => (t ? new Date(t).toISOString() : null);++++/** One model row (with its live offers' figures) → what its page says, as search-feed's document description. */++function describe(row) {++ const stores = Number(row.store_count) || 0;++ const from = dollars(row.price_min);++ const where = stores ? `${stores} store${stores === 1 ? '' : 's'}` : null;++ const price = from && where ? `From ${from} at ${where} today.` : where ? `Sold at ${where} today.` : 'No store lists it right now.';++ return {++ listed: true,++ noindex: !stores,++ title: row.name,++ summary: `${row.name} by ${row.brand}. ${price} Every store's price, the price history and the size curve on open.shoes.`,++ body: [row.name, row.brand, row.model, price].filter(Boolean).join('\n'),++ facets: { brand: row.brand, stores, ...(row.price_min != null ? { lowest_price: Math.round(Number(row.price_min)) } : {}) },++ authorship: { mode: 'imported' },++ publishedAt: iso(row.first_seen_at),++ updatedAt: iso(row.checked_at || row.updated_at),++ };++}++++function createSearchIndex({ config, s, log = console, outbox = null }) {++ const db = s.db;++ const out = outbox || createServiceOutbox({++ db, source: 'shoes', eventsUrl: config.events.url || null, networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, log, eventTypes: EVENT_TYPES,++ });++ // The page is /shoe/<slug>, so the slug is the document's id: rows come back keyed by it, in slug order.++ const rows = async (after, limit) => db.many(++ `SELECT m.slug AS id, m.name, m.brand, m.model, m.first_seen_at, m.updated_at,++ (SELECT MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) FROM shoe_offers o++ WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL AND o.currency = 'USD') AS price_min,++ (SELECT COUNT(DISTINCT o.store_domain)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS store_count,++ (SELECT MAX(o.checked_at) FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS checked_at++ FROM shoe_models m WHERE m.slug > $1 ORDER BY m.slug LIMIT $2`,++ [String(after || ''), limit],++ );++ const feed = createSearchFeed({++ owner: 'shoes', db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,++ types: {++ shoe: {++ page: (row) => `/shoe/${row.id}`,++ document: describe,++ rows,++ exists: async (ids) => (ids.length ? (await db.many('SELECT slug FROM shoe_models WHERE slug = ANY($1::text[])', [ids])).map((r) => r.slug) : []),++ },++ },++ });++ const timers = [];++++ async function sweep() {++ const res = await feed.sweep();++ const r = res.shoe;++ if (r.sent || r.removed || r.failed) log.log(`[Search] shoe models: ${r.sent} sent, ${r.removed} removed, ${r.failed} failed of ${r.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 (config.drill || 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, sweep, start, stop, status: () => out.status() };++}++++module.exports = { createSearchIndex, describe, EVENT_TYPES };nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * Shoe models in OpenVibe.Search (server/search-index.js): a sweep puts every model in the outbox as one++ * shoes.index_document.upserted, exactly the contract (contracts 0.273.0), with its /shoe/<slug> page (which answers),++ * its brand, store count and lowest price; a second sweep sends nothing; a model that left the catalogue becomes a++ * tombstone, once.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { describe } = require('../server/search-index');++++(async () => {++ const t = await boot();++ const { s, 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)}`);++ assert.strictEqual(env.source, 'shoes');++ return env;++ };++++ await t.ctx.jobs.offers.runOnce({ pages: 2 });++ const models = await s.db.many('SELECT slug FROM shoe_models ORDER BY slug');++++ await check('a sweep sends every model once, as the contract says, and the page it names answers', async () => {++ assert.ok(models.length >= 2, `the offers job made models (${models.length})`);++ const res = await searchIndex.sweep();++ assert.deepStrictEqual(res.shoe, { seen: models.length, sent: models.length, removed: 0, failed: 0 });++ const envs = (await outbox()).map(valid);++ const doc = envs.find((e) => e.payload.id === models[0].slug).payload;++ assert.strictEqual(doc.canonical_url, `${t.config.baseUrl}/shoe/${models[0].slug}`);++ assert.strictEqual(doc.type, 'shoe');++ assert.ok(doc.title && doc.facets.brand, 'a name and a brand');++ assert.match(doc.summary, /store/);++ assert.strictEqual((await t.get(`/shoe/${models[0].slug}`)).status, 200);++ assert.strictEqual((await searchIndex.sweep()).shoe.sent, 0, 'a second sweep sends nothing');++ });++++ await check('a model with no live offer is noindex; with offers, the lowest dollar price and the store count', async () => {++ const none = describe({ name: 'Plain Runner', brand: 'Acme', model: 'Plain Runner', store_count: 0, price_min: null });++ assert.strictEqual(none.noindex, true);++ assert.match(none.summary, /No store lists it right now/);++ const some = describe({ name: 'Air Max 90', brand: 'Nike', model: 'Air Max 90', store_count: 3, price_min: '98.40' });++ assert.strictEqual(some.noindex, false);++ assert.match(some.summary, /From \$98 at 3 stores today/);++ assert.deepStrictEqual(some.facets, { brand: 'Nike', stores: 3, lowest_price: 98 });++ });++++ await check('a model that left the catalogue becomes a tombstone, once', async () => {++ const gone = models[models.length - 1].slug;++ await s.db.many('DELETE FROM shoe_models WHERE slug = $1 RETURNING slug', [gone]);++ assert.strictEqual((await searchIndex.sweep()).shoe.removed, 1);++ const env = valid((await outbox()).pop());++ assert.strictEqual(env.event_type, 'shoes.index_document.deleted');++ assert.deepStrictEqual([env.payload.type, env.payload.id], ['shoe', gone]);++ assert.strictEqual((await searchIndex.sweep()).shoe.removed, 0);++ });++++ done();++})();
@@ -159,7 +159,8 @@ section says so plainly when no store has cut a price yet.
@@ -159,7 +159,8 @@ section says so plainly when no store has cut a price yet.
- No affiliate links, no sponsored order, no Marketplace scraping.
- No affiliate links, no sponsored order, no Marketplace scraping.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.265.0
+- openvibe-contracts: v0.273.0
+- openvibe-publishing: v1.4.1
- openvibe-sdk: v0.81.0
- openvibe-sdk: v0.81.0
- openvibe-shared: v4.103.0
- openvibe-shared: v4.103.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,12 @@
@@ -7,11 +7,12 @@
"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": "5180",
"port": "5180",
"domain": "open.shoes",
"domain": "open.shoes",
- "contracts": "openvibe-contracts v0.265.0",
+ "contracts": "openvibe-contracts v0.273.0",
"sdk": "openvibe-sdk v0.81.0",
"sdk": "openvibe-sdk v0.81.0",
"shared": "openvibe-shared v4.103.0",
"shared": "openvibe-shared v4.103.0",
"packages": [
"packages": [
- "openvibe-contracts v0.265.0",
+ "openvibe-contracts v0.273.0",
+ "openvibe-publishing v1.4.1",
"openvibe-sdk v0.81.0",
"openvibe-sdk v0.81.0",
"openvibe-shared v4.103.0"
"openvibe-shared v4.103.0"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+-- phase: expand
+-- OpenVibe.Shoes: shoe model pages in OpenVibe.Search (server/search-index.js, openvibe-publishing/search-feed).
+-- event_outbox holds the shoes.index_document.* events until the relay delivers them to OpenVibe.Events (written in
+-- the same transaction as the revision that produced them); shoes_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 shoes_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.265.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.273.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
@@ -1150,9 +1151,9 @@
@@ -1150,9 +1151,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.265.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",
- "integrity": "sha512-0KnKj1Rig6acZcv4U2uwaM2msarGUY36pNqFbqmKdLeOPPozf7haEDTam27A/WzSU9ii2yEZ6SnuusPPQoGZaA==",
+ "version": "0.273.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.273.0",
+ "integrity": "sha512-NgcTWnKG1UwHZOLYlJLo8usPWxeld7Cnh2doFDfjUvL6duKqru7aO9hKjM0fYFROWvSHKaURLYhQLQOpJEhdog==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1165,6 +1166,24 @@
@@ -1165,6 +1166,24 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-publishing": {
+ "version": "1.4.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
+ "integrity": "sha512-YjrcxCnXbpDQwyYpgsRcDj0+ckqZBm/ErEmQjvf0mCFMImwErG+lzZKXnXWb6eWOPxXEVj9YYuOgZiWVL41Vfg==",
+ "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.81.0",
"version": "0.81.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
@@ -23,7 +23,8 @@
@@ -23,7 +23,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.265.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.273.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
@@ -35,6 +35,7 @@ const { assetVersion, setRelease } = require('./render/layout');
@@ -35,6 +35,7 @@ const { assetVersion, setRelease } = require('./render/layout');
const { createCatalogue } = require('./catalogue');
const { createCatalogue } = require('./catalogue');
const { createUpstreams } = require('./upstreams');
const { createUpstreams } = require('./upstreams');
const { createJobs } = require('./jobs');
const { createJobs } = require('./jobs');
+const { createSearchIndex } = require('./search-index');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@ -60,6 +61,8 @@ async function createApp(opts = {}) {
@@ -60,6 +61,8 @@ async function createApp(opts = {}) {
const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });
const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });
const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });
const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });
const jobs = createJobs({ config, catalogue, upstreams, log });
const jobs = createJobs({ config, catalogue, upstreams, log });
+ // The model pages in OpenVibe.Search (started by server/index.js, never under SHOES_DRILL).
+ const searchIndex = createSearchIndex({ config, s, log });
// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
// client-credentials token; a test injects a stand-in through opts.accountSend.
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@ -68,7 +71,7 @@ async function createApp(opts = {}) {
@@ -68,7 +71,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });
- const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, accountData, accountSend, log };
+ const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, searchIndex, accountData, accountSend, log };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -20,6 +20,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
@@ -20,6 +20,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => { for (const t of timers) clearInterval(t); },
() => { for (const t of timers) clearInterval(t); },
() => ctx.keys.client.stop(),
() => ctx.keys.client.stop(),
() => ctx.jobs.stop(),
() => ctx.jobs.stop(),
+ () => { if (ctx.searchIndex) ctx.searchIndex.stop(); },
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
() => ctx.s.close(),
() => ctx.s.close(),
...extra,
...extra,
@@ -45,6 +46,8 @@ async function start() {
@@ -45,6 +46,8 @@ async function start() {
// Jobs. Each refuses to start under SHOES_DRILL; tests call their runOnce().
// Jobs. Each refuses to start under SHOES_DRILL; tests call their runOnce().
ctx.jobs.start();
ctx.jobs.start();
+ // The model pages in OpenVibe.Search: a sweep a minute after boot and every six hours (off under SHOES_DRILL).
+ ctx.searchIndex.start();
// The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
// The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
// delivery secret and the client secret. The consumer itself is mounted in server/app.js.
// delivery secret and the client secret. The consumer itself is mounted in server/app.js.
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * open.shoes' model pages in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.273.0): one
+ * search.index-document@1 per shoe model (/shoe/<slug>, the slug as the id), sent as shoes.index_document.upserted|deleted
+ * through this service's events outbox. The document is what the page says: the model's name and brand, the lowest
+ * all-in price seen today (in dollars, the way the page shows it) and how many stores sell it. A network search for a
+ * shoe found deals and coupons but never the one page with every store's price (2026-11-02).
+ *
+ * A model with no live offer is a thin page, kept in Search but noindex; a model that left the catalogue gets a
+ * tombstone (exists()). The catalogue is small (199 models on 2026-11-02) and its prices move through the day, so a
+ * sweep a minute after boot and every six hours is enough: the sequencer (shoes_index_revisions) gives an unchanged
+ * document its old revision, and a quiet sweep sends nothing. Off under SHOES_DRILL.
+ */
+const { createServiceOutbox } = require('openvibe-sdk/events');
+const { createSearchFeed } = require('openvibe-publishing/search-feed');
+
+const EVENT_TYPES = ['shoes.index_document.upserted', 'shoes.index_document.deleted'];
+const START_DELAY_MS = 60_000;
+const INTERVAL_MS = 6 * 3_600_000;
+
+const dollars = (n) => (n == null || !Number.isFinite(Number(n)) ? null : `$${Math.round(Number(n)).toLocaleString('en-US')}`);
+const iso = (t) => (t ? new Date(t).toISOString() : null);
+
+/** One model row (with its live offers' figures) → what its page says, as search-feed's document description. */
+function describe(row) {
+ const stores = Number(row.store_count) || 0;
+ const from = dollars(row.price_min);
+ const where = stores ? `${stores} store${stores === 1 ? '' : 's'}` : null;
+ const price = from && where ? `From ${from} at ${where} today.` : where ? `Sold at ${where} today.` : 'No store lists it right now.';
+ return {
+ listed: true,
+ noindex: !stores,
+ title: row.name,
+ summary: `${row.name} by ${row.brand}. ${price} Every store's price, the price history and the size curve on open.shoes.`,
+ body: [row.name, row.brand, row.model, price].filter(Boolean).join('\n'),
+ facets: { brand: row.brand, stores, ...(row.price_min != null ? { lowest_price: Math.round(Number(row.price_min)) } : {}) },
+ authorship: { mode: 'imported' },
+ publishedAt: iso(row.first_seen_at),
+ updatedAt: iso(row.checked_at || row.updated_at),
+ };
+}
+
+function createSearchIndex({ config, s, log = console, outbox = null }) {
+ const db = s.db;
+ const out = outbox || createServiceOutbox({
+ db, source: 'shoes', eventsUrl: config.events.url || null, networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, log, eventTypes: EVENT_TYPES,
+ });
+ // The page is /shoe/<slug>, so the slug is the document's id: rows come back keyed by it, in slug order.
+ const rows = async (after, limit) => db.many(
+ `SELECT m.slug AS id, m.name, m.brand, m.model, m.first_seen_at, m.updated_at,
+ (SELECT MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) FROM shoe_offers o
+ WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL AND o.currency = 'USD') AS price_min,
+ (SELECT COUNT(DISTINCT o.store_domain)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS store_count,
+ (SELECT MAX(o.checked_at) FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS checked_at
+ FROM shoe_models m WHERE m.slug > $1 ORDER BY m.slug LIMIT $2`,
+ [String(after || ''), limit],
+ );
+ const feed = createSearchFeed({
+ owner: 'shoes', db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,
+ types: {
+ shoe: {
+ page: (row) => `/shoe/${row.id}`,
+ document: describe,
+ rows,
+ exists: async (ids) => (ids.length ? (await db.many('SELECT slug FROM shoe_models WHERE slug = ANY($1::text[])', [ids])).map((r) => r.slug) : []),
+ },
+ },
+ });
+ const timers = [];
+
+ async function sweep() {
+ const res = await feed.sweep();
+ const r = res.shoe;
+ if (r.sent || r.removed || r.failed) log.log(`[Search] shoe models: ${r.sent} sent, ${r.removed} removed, ${r.failed} failed of ${r.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 (config.drill || 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, sweep, start, stop, status: () => out.status() };
+}
+
+module.exports = { createSearchIndex, describe, EVENT_TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * Shoe models in OpenVibe.Search (server/search-index.js): a sweep puts every model in the outbox as one
+ * shoes.index_document.upserted, exactly the contract (contracts 0.273.0), with its /shoe/<slug> page (which answers),
+ * its brand, store count and lowest price; a second sweep sends nothing; a model that left the catalogue becomes a
+ * tombstone, once.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { describe } = require('../server/search-index');
+
+(async () => {
+ const t = await boot();
+ const { s, 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)}`);
+ assert.strictEqual(env.source, 'shoes');
+ return env;
+ };
+
+ await t.ctx.jobs.offers.runOnce({ pages: 2 });
+ const models = await s.db.many('SELECT slug FROM shoe_models ORDER BY slug');
+
+ await check('a sweep sends every model once, as the contract says, and the page it names answers', async () => {
+ assert.ok(models.length >= 2, `the offers job made models (${models.length})`);
+ const res = await searchIndex.sweep();
+ assert.deepStrictEqual(res.shoe, { seen: models.length, sent: models.length, removed: 0, failed: 0 });
+ const envs = (await outbox()).map(valid);
+ const doc = envs.find((e) => e.payload.id === models[0].slug).payload;
+ assert.strictEqual(doc.canonical_url, `${t.config.baseUrl}/shoe/${models[0].slug}`);
+ assert.strictEqual(doc.type, 'shoe');
+ assert.ok(doc.title && doc.facets.brand, 'a name and a brand');
+ assert.match(doc.summary, /store/);
+ assert.strictEqual((await t.get(`/shoe/${models[0].slug}`)).status, 200);
+ assert.strictEqual((await searchIndex.sweep()).shoe.sent, 0, 'a second sweep sends nothing');
+ });
+
+ await check('a model with no live offer is noindex; with offers, the lowest dollar price and the store count', async () => {
+ const none = describe({ name: 'Plain Runner', brand: 'Acme', model: 'Plain Runner', store_count: 0, price_min: null });
+ assert.strictEqual(none.noindex, true);
+ assert.match(none.summary, /No store lists it right now/);
+ const some = describe({ name: 'Air Max 90', brand: 'Nike', model: 'Air Max 90', store_count: 3, price_min: '98.40' });
+ assert.strictEqual(some.noindex, false);
+ assert.match(some.summary, /From \$98 at 3 stores today/);
+ assert.deepStrictEqual(some.facets, { brand: 'Nike', stores: 3, lowest_price: 98 });
+ });
+
+ await check('a model that left the catalogue becomes a tombstone, once', async () => {
+ const gone = models[models.length - 1].slug;
+ await s.db.many('DELETE FROM shoe_models WHERE slug = $1 RETURNING slug', [gone]);
+ assert.strictEqual((await searchIndex.sweep()).shoe.removed, 1);
+ const env = valid((await outbox()).pop());
+ assert.strictEqual(env.event_type, 'shoes.index_document.deleted');
+ assert.deepStrictEqual([env.payload.type, env.payload.id], ['shoe', gone]);
+ assert.strictEqual((await searchIndex.sweep()).shoe.removed, 0);
+ });
+
+ done();
+})();