openvibers/openvibe.inventory Commits
Inventory: item pages in OpenVibe.Search (search-feed, inventory.index_document.*) (#19)
Changes
@@@ -221,7 +221,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.37.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": "5030","domain": "inventory.openvibe.network",-- "contracts": "openvibe-contracts v0.127.0",++ "contracts": "openvibe-contracts v0.128.0","sdk": "openvibe-sdk v0.37.0","shared": "openvibe-shared v2.21.0","packages": [-- "openvibe-contracts v0.127.0",++ "openvibe-contracts v0.128.0","openvibe-sdk v0.37.0",-- "openvibe-shared v2.21.0"++ "openvibe-shared v2.21.0",++ "openvibe-publishing v1.4.0"],"summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.","works": [nnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: expand++-- OpenVibe.Inventory: item pages in OpenVibe.Search (server/search-index.js, openvibe-publishing/search-feed).++-- inventory_index_revisions is the index sequencer: the last revision and content hash Search was sent for each item++-- page, so an unchanged page is never sent twice. The inventory.index_document.* events go through the existing++-- event_outbox (0001), in the same transaction as the revision that produced them.++++CREATE TABLE IF NOT EXISTS inventory_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)++);@@@ -17,7 +17,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.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0","pg": "^8.23.0"@@@ -884,9 +885,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",@@@ -899,6 +900,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.37.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",@@@ -2,7 +2,7 @@"name": "openvibe-inventory","version": "0.4.1","private": true,-- "description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",++ "description": "OpenVibe.Inventory \u2014 Your items, on every OpenVibe site.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -24,7 +24,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.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0","pg": "^8.23.0"@@@ -34,6 +34,7 @@ const { createPeople } = require('./workshop/people');const { createServiceReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');const { createServiceOutbox } = require('openvibe-sdk/events');++const { createSearchIndex } = require('./search-index');const { createNetworkSender } = require('openvibe-sdk/account-data');const { createInventory } = require('./inventory/store');const { seed } = require('./inventory/seed');@@@ -66,13 +67,17 @@ async function createApp(opts = {}) {});const inv = createInventory({ s, outbox, log });await seed(inv, { log });++ // Item pages in OpenVibe.Search (./search-index.js) through the same outbox: the definition writes name what they++ // changed (watch), and a sweep runs on its own timer once server/index.js starts it.++ const searchIndex = opts.searchIndex || createSearchIndex({ config, s, outbox, log });++ searchIndex.watch(inv);// Account export and deletion (ADR-033): the table map, and the sender to Network's internal routes with this// service's own client-credentials token.const accountData = accountDataLib.create({ db: s.db, log });const accountSend = opts.accountSend || (config.oauth.clientSecret? 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: Inventory cannot answer account events'); });-- const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData };++ const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData, searchIndex };// The Workshop (ADR-054 §6): images in this service's own Media tenant, recipients by @name through the Network.ctx.workshopMedia = opts.workshopMedia || createWorkshopMedia({ config, fetchImpl: opts.fetchImpl, log });ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });@@@ -16,7 +16,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {return gracefulStop({name: 'OpenVibe.Inventory', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,-- close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.outbox.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],++ close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.searchIndex.stop(), () => ctx.outbox.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],});}@@@ -30,6 +30,7 @@ async function start() {server.keepAliveTimeout = 65_000;ctx.keys.client.start();ctx.outbox.start();++ ctx.searchIndex.start();// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,// INVENTORY_EVENTS_SECRET or the client secret.const subscriptions = startSubscriptions({nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++++/**++ * Inventory's item pages in OpenVibe.Search (openvibe-publishing/search-feed): one search.index-document@1 per item++ * definition page (/items/<id>), sent as inventory.index_document.upserted|deleted through the outbox Inventory's own++ * events already use. A published item is a document; a retired one stays (its page still answers) but is marked++ * noindex, as its page is; a draft or an item in review is a tombstone, as its page answers 404. The document carries++ * what the page shows: name, rarity, kind, description, who gives it and how to get it. Never who owns it.++ *++ * The write paths that change a definition (create, edit, Workshop edit, review, takedown) name it to touch() (see++ * server/app.js); touched items sync a moment later, each in its own transaction. A sweep a minute after boot and++ * every ten minutes catches anything else (the seed, an account deletion that makes Workshop items authorless):++ * inventory_index_revisions is the sequencer, so it re-sends only what changed.++ */++const { createSearchFeed } = require('openvibe-publishing/search-feed');++++const FLUSH_MS = 250;++const START_DELAY_MS = 60_000;++const INTERVAL_MS = 10 * 60_000;++const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };++const SELECT = `SELECT d.*, k.name AS kind_name, k.slots AS kind_slots FROM inventory_definitions d JOIN inventory_kinds k ON k.id = d.kind`;++++const issuerName = (row) => (String(row.issuer).startsWith('service:')++ ? `OpenVibe.${row.issuer.slice(8).replace(/^./, (c) => c.toUpperCase())}`++ : row.credit_name || 'a creator');++const listOf = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);++++/** One definition row (with its kind's name) → what its page shows, as search-feed's document description. */++function describe(row) {++ const rarity = RARITY_LABEL[row.rarity] || row.rarity;++ const kind = String(row.kind_name || row.kind);++ const fromQuest = listOf(row.grantors).includes('service:quest');++ const wearable = listOf(row.kind_slots).length > 0;++ return {++ listed: row.status === 'published' || row.status === 'retired',++ noindex: row.status === 'retired',++ title: `${row.name} · ${rarity} ${kind.toLowerCase()}`,++ summary: row.description || `${row.name}, a ${rarity.toLowerCase()} ${kind.toLowerCase()} on OpenVibe.`,++ body: [row.name, `${rarity} ${kind}`, row.description, wearable ? 'Wearable' : 'Collectible', `Given by ${issuerName(row)}`,++ fromQuest ? 'Earn it on OpenVibe.Quest: the reward for one of the quests there.' : ''].filter(Boolean).join('\n'),++ facets: { kind: row.kind, rarity: row.rarity, issuer: row.issuer, wearable },++ authorship: { mode: 'human' },++ publishedAt: row.published_at || row.created_at,++ updatedAt: row.updated_at,++ };++}++++function createSearchIndex({ config, s, outbox, log = console }) {++ const feed = createSearchFeed({++ owner: 'inventory', db: s.db, outbox, baseUrl: config.baseUrl, now: s.now, log,++ types: {++ definition: {++ page: (row) => `/items/${row.id}`,++ document: describe,++ rows: (after, limit) => s.db.many(`${SELECT} WHERE d.id > $1 ORDER BY d.id LIMIT $2`, [after, limit]),++ exists: async (ids) => (await s.db.many('SELECT id FROM inventory_definitions WHERE id = ANY($1)', [ids])).map((r) => r.id),++ },++ },++ });++ const pending = new Set();++ const timers = [];++ let flushTimer = null;++ let flushing = null;++++ async function syncId(id) {++ return await s.db.tx(async (t) => {++ const row = await t.maybe(`${SELECT} WHERE d.id = $1`, [id]);++ return row ? await feed.sync(t, 'definition', row) : await feed.remove(t, 'definition', id);++ });++ }++++ async function flush() {++ flushTimer = null;++ if (flushing) await flushing;++ const ids = [...pending];++ pending.clear();++ flushing = (async () => {++ for (const id of ids) {++ try { await syncId(id); } catch (err) { log.warn(`[Search] item ${id}: ${(err && err.message) || err}`); }++ }++ if (ids.length && outbox.kick) outbox.kick().catch(() => {});++ })();++ try { await flushing; } finally { flushing = null; }++ }++++ /** A write changed this definition: sync it shortly (never awaited by the request, never fatal). */++ function touch(id) {++ if (!id) return;++ pending.add(String(id));++ if (!flushTimer) {++ flushTimer = setTimeout(() => { flush().catch(() => {}); }, FLUSH_MS);++ if (flushTimer.unref) flushTimer.unref();++ }++ }++++ async function sweep() {++ const res = await feed.sweep();++ const d = res.definition;++ if (d.sent || d.removed || d.failed) log.log(`[Search] items: ${d.sent} sent, ${d.removed} removed, ${d.failed} failed of ${d.seen}`);++ if (outbox.kick) outbox.kick().catch(() => {});++ return res;++ }++ const quietly = () => { sweep().catch((err) => log.warn(`[Search] sweep failed: ${(err && err.message) || err}`)); };++++ /** The outbox is Inventory's own: started and stopped by server/index.js, not here. */++ function start() {++ if (timers.length) return false;++ 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); }++ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }++ }++++ async function settle() {++ if (flushTimer) { clearTimeout(flushTimer); await flush(); }++ if (flushing) await flushing;++ }++++ /**++ * Wrap the inventory's definition writes so each names what it changed. → the same object, for chaining. A write++ * answers { …definition } or { definition, … }; anything else names nothing.++ */++ function watch(inv) {++ for (const m of ['createDefinition', 'updateDefinition', 'updateWorkshopItem', 'review', 'takedown']) {++ const fn = inv[m];++ if (typeof fn !== 'function') continue;++ inv[m] = async (...args) => {++ const out = await fn(...args);++ const id = out && (out.definition ? out.definition.id : out.id);++ if (id) touch(id);++ return out;++ };++ }++ return inv;++ }++++ return { feed, describe, touch, syncId, sweep, settle, start, stop, watch };++}++++module.exports = { createSearchIndex, describe };@@@ -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,76 @@++'use strict';++/**++ * Item pages in OpenVibe.Search (server/search-index.js): a sweep puts every published item in the outbox as one++ * inventory.index_document.upserted, exactly the contract, with its /items/<id> page (which answers); a second sweep++ * sends nothing; a retired item stays, marked noindex, like its page; a draft is a tombstone, like its 404; the++ * definition writes name what they changed.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const { s, searchIndex } = t.ctx;++ const docs = async () => (await s.db.many("SELECT envelope FROM event_outbox WHERE envelope->>'event_type' LIKE 'inventory.index_document.%' 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, 'inventory');++ return env;++ };++ let id;++++ try {++ await check('a sweep sends every published item once, as the contract says, and its page answers', async () => {++ const published = await s.db.many("SELECT * FROM inventory_definitions WHERE status = 'published' ORDER BY id");++ assert.ok(published.length > 3, 'the seed published items');++ const res = await searchIndex.sweep();++ assert.strictEqual(res.definition.failed, 0);++ const envs = (await docs()).map(valid);++ assert.strictEqual(envs.length, published.length);++ id = published[0].id;++ const doc = envs.find((e) => e.payload.id === id).payload;++ assert.strictEqual(doc.canonical_url, `${t.config.baseUrl}/items/${id}`);++ assert.ok(doc.title.startsWith(`${published[0].name} · `));++ assert.strictEqual(doc.facets.rarity, published[0].rarity);++ assert.deepStrictEqual(doc.indexability, { decision: 'index', reasons: [] });++ assert.strictEqual((await t.get(`/items/${id}`)).status, 200);++ assert.strictEqual((await searchIndex.sweep()).definition.sent, 0, 'a second sweep sends nothing');++ });++++ await check('a retired item stays in Search marked noindex; a draft is a tombstone', async () => {++ await s.db.exec("UPDATE inventory_definitions SET status = 'retired' WHERE id = $1", [id]);++ await searchIndex.syncId(id);++ let env = valid((await docs()).pop());++ assert.strictEqual(env.event_type, 'inventory.index_document.upserted');++ assert.strictEqual(env.payload.indexability.decision, 'noindex');++ await s.db.exec("UPDATE inventory_definitions SET status = 'draft' WHERE id = $1", [id]);++ await searchIndex.syncId(id);++ env = valid((await docs()).pop());++ assert.strictEqual(env.event_type, 'inventory.index_document.deleted');++ assert.strictEqual((await t.get(`/items/${id}`)).status, 404);++ await s.db.exec("UPDATE inventory_definitions SET status = 'published' WHERE id = $1", [id]);++ });++++ await check('the definition writes name what they changed, and the change is synced', async () => {++ // watch() wraps createDefinition, updateDefinition, updateWorkshopItem, review and takedown; a write answers++ // the definition ({ id }) or { definition }, and that id is synced a moment later.++ await s.db.exec("UPDATE inventory_definitions SET description = 'Burns brighter.' WHERE id = $1", [id]);++ const before = (await docs()).length;++ const wrapped = searchIndex.watch({ review: async () => ({ id }), takedown: async () => ({ definition: { id }, revoked: 0 }), grant: async () => ({ id: 'not-a-definition-write' }) });++ await wrapped.review();++ await searchIndex.settle();++ const after = await docs();++ assert.strictEqual(after.length, before + 1, 'the touched item was synced once');++ assert.match(valid(after[after.length - 1]).payload.summary, /Burns brighter/);++ await wrapped.takedown();++ await wrapped.grant();++ await searchIndex.settle();++ assert.strictEqual((await docs()).length, before + 1, 'unchanged since: nothing more; grant is not wrapped');++ });++ } finally {++ await done(t);++ }++})();
@@ -221,7 +221,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -221,7 +221,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.37.0
- openvibe-sdk: v0.37.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": "5030",
"port": "5030",
"domain": "inventory.openvibe.network",
"domain": "inventory.openvibe.network",
- "contracts": "openvibe-contracts v0.127.0",
+ "contracts": "openvibe-contracts v0.128.0",
"sdk": "openvibe-sdk v0.37.0",
"sdk": "openvibe-sdk v0.37.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.37.0",
"openvibe-sdk v0.37.0",
- "openvibe-shared v2.21.0"
+ "openvibe-shared v2.21.0",
+ "openvibe-publishing v1.4.0"
],
],
"summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.",
"summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.",
"works": [
"works": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: expand
+-- OpenVibe.Inventory: item pages in OpenVibe.Search (server/search-index.js, openvibe-publishing/search-feed).
+-- inventory_index_revisions is the index sequencer: the last revision and content hash Search was sent for each item
+-- page, so an unchanged page is never sent twice. The inventory.index_document.* events go through the existing
+-- event_outbox (0001), in the same transaction as the revision that produced them.
+
+CREATE TABLE IF NOT EXISTS inventory_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)
+);
@@ -17,7 +17,8 @@
@@ -17,7 +17,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.37.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.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"
@@ -884,9 +885,9 @@
@@ -884,9 +885,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",
@@ -899,6 +900,24 @@
@@ -899,6 +900,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.37.0",
"version": "0.37.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-inventory",
"name": "openvibe-inventory",
"version": "0.4.1",
"version": "0.4.1",
"private": true,
"private": true,
- "description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
+ "description": "OpenVibe.Inventory \u2014 Your items, on every OpenVibe site.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -24,7 +24,8 @@
@@ -24,7 +24,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.37.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.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"
@@ -34,6 +34,7 @@ const { createPeople } = require('./workshop/people');
@@ -34,6 +34,7 @@ const { createPeople } = require('./workshop/people');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
+const { createSearchIndex } = require('./search-index');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createInventory } = require('./inventory/store');
const { createInventory } = require('./inventory/store');
const { seed } = require('./inventory/seed');
const { seed } = require('./inventory/seed');
@@ -66,13 +67,17 @@ async function createApp(opts = {}) {
@@ -66,13 +67,17 @@ async function createApp(opts = {}) {
});
});
const inv = createInventory({ s, outbox, log });
const inv = createInventory({ s, outbox, log });
await seed(inv, { log });
await seed(inv, { log });
+ // Item pages in OpenVibe.Search (./search-index.js) through the same outbox: the definition writes name what they
+ // changed (watch), and a sweep runs on its own timer once server/index.js starts it.
+ const searchIndex = opts.searchIndex || createSearchIndex({ config, s, outbox, log });
+ searchIndex.watch(inv);
// Account export and deletion (ADR-033): the table map, and the sender to Network's internal routes with this
// Account export and deletion (ADR-033): the table map, and the sender to Network's internal routes with this
// service's own client-credentials token.
// service's own client-credentials token.
const accountData = accountDataLib.create({ db: s.db, log });
const accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? 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: Inventory cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Inventory cannot answer account events'); });
- const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData };
+ const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData, searchIndex };
// The Workshop (ADR-054 §6): images in this service's own Media tenant, recipients by @name through the Network.
// The Workshop (ADR-054 §6): images in this service's own Media tenant, recipients by @name through the Network.
ctx.workshopMedia = opts.workshopMedia || createWorkshopMedia({ config, fetchImpl: opts.fetchImpl, log });
ctx.workshopMedia = opts.workshopMedia || createWorkshopMedia({ config, fetchImpl: opts.fetchImpl, log });
ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });
ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });
@@ -16,7 +16,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');
@@ -16,7 +16,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'OpenVibe.Inventory', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
name: 'OpenVibe.Inventory', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
- close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.outbox.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],
+ close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.searchIndex.stop(), () => ctx.outbox.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],
});
});
}
}
@@ -30,6 +30,7 @@ async function start() {
@@ -30,6 +30,7 @@ async function start() {
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
ctx.keys.client.start();
ctx.outbox.start();
ctx.outbox.start();
+ ctx.searchIndex.start();
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// INVENTORY_EVENTS_SECRET or the client secret.
// INVENTORY_EVENTS_SECRET or the client secret.
const subscriptions = startSubscriptions({
const subscriptions = startSubscriptions({
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+
+/**
+ * Inventory's item pages in OpenVibe.Search (openvibe-publishing/search-feed): one search.index-document@1 per item
+ * definition page (/items/<id>), sent as inventory.index_document.upserted|deleted through the outbox Inventory's own
+ * events already use. A published item is a document; a retired one stays (its page still answers) but is marked
+ * noindex, as its page is; a draft or an item in review is a tombstone, as its page answers 404. The document carries
+ * what the page shows: name, rarity, kind, description, who gives it and how to get it. Never who owns it.
+ *
+ * The write paths that change a definition (create, edit, Workshop edit, review, takedown) name it to touch() (see
+ * server/app.js); touched items sync a moment later, each in its own transaction. A sweep a minute after boot and
+ * every ten minutes catches anything else (the seed, an account deletion that makes Workshop items authorless):
+ * inventory_index_revisions is the sequencer, so it re-sends only what changed.
+ */
+const { createSearchFeed } = require('openvibe-publishing/search-feed');
+
+const FLUSH_MS = 250;
+const START_DELAY_MS = 60_000;
+const INTERVAL_MS = 10 * 60_000;
+const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
+const SELECT = `SELECT d.*, k.name AS kind_name, k.slots AS kind_slots FROM inventory_definitions d JOIN inventory_kinds k ON k.id = d.kind`;
+
+const issuerName = (row) => (String(row.issuer).startsWith('service:')
+ ? `OpenVibe.${row.issuer.slice(8).replace(/^./, (c) => c.toUpperCase())}`
+ : row.credit_name || 'a creator');
+const listOf = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
+
+/** One definition row (with its kind's name) → what its page shows, as search-feed's document description. */
+function describe(row) {
+ const rarity = RARITY_LABEL[row.rarity] || row.rarity;
+ const kind = String(row.kind_name || row.kind);
+ const fromQuest = listOf(row.grantors).includes('service:quest');
+ const wearable = listOf(row.kind_slots).length > 0;
+ return {
+ listed: row.status === 'published' || row.status === 'retired',
+ noindex: row.status === 'retired',
+ title: `${row.name} · ${rarity} ${kind.toLowerCase()}`,
+ summary: row.description || `${row.name}, a ${rarity.toLowerCase()} ${kind.toLowerCase()} on OpenVibe.`,
+ body: [row.name, `${rarity} ${kind}`, row.description, wearable ? 'Wearable' : 'Collectible', `Given by ${issuerName(row)}`,
+ fromQuest ? 'Earn it on OpenVibe.Quest: the reward for one of the quests there.' : ''].filter(Boolean).join('\n'),
+ facets: { kind: row.kind, rarity: row.rarity, issuer: row.issuer, wearable },
+ authorship: { mode: 'human' },
+ publishedAt: row.published_at || row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+function createSearchIndex({ config, s, outbox, log = console }) {
+ const feed = createSearchFeed({
+ owner: 'inventory', db: s.db, outbox, baseUrl: config.baseUrl, now: s.now, log,
+ types: {
+ definition: {
+ page: (row) => `/items/${row.id}`,
+ document: describe,
+ rows: (after, limit) => s.db.many(`${SELECT} WHERE d.id > $1 ORDER BY d.id LIMIT $2`, [after, limit]),
+ exists: async (ids) => (await s.db.many('SELECT id FROM inventory_definitions WHERE id = ANY($1)', [ids])).map((r) => r.id),
+ },
+ },
+ });
+ const pending = new Set();
+ const timers = [];
+ let flushTimer = null;
+ let flushing = null;
+
+ async function syncId(id) {
+ return await s.db.tx(async (t) => {
+ const row = await t.maybe(`${SELECT} WHERE d.id = $1`, [id]);
+ return row ? await feed.sync(t, 'definition', row) : await feed.remove(t, 'definition', id);
+ });
+ }
+
+ async function flush() {
+ flushTimer = null;
+ if (flushing) await flushing;
+ const ids = [...pending];
+ pending.clear();
+ flushing = (async () => {
+ for (const id of ids) {
+ try { await syncId(id); } catch (err) { log.warn(`[Search] item ${id}: ${(err && err.message) || err}`); }
+ }
+ if (ids.length && outbox.kick) outbox.kick().catch(() => {});
+ })();
+ try { await flushing; } finally { flushing = null; }
+ }
+
+ /** A write changed this definition: sync it shortly (never awaited by the request, never fatal). */
+ function touch(id) {
+ if (!id) return;
+ pending.add(String(id));
+ if (!flushTimer) {
+ flushTimer = setTimeout(() => { flush().catch(() => {}); }, FLUSH_MS);
+ if (flushTimer.unref) flushTimer.unref();
+ }
+ }
+
+ async function sweep() {
+ const res = await feed.sweep();
+ const d = res.definition;
+ if (d.sent || d.removed || d.failed) log.log(`[Search] items: ${d.sent} sent, ${d.removed} removed, ${d.failed} failed of ${d.seen}`);
+ if (outbox.kick) outbox.kick().catch(() => {});
+ return res;
+ }
+ const quietly = () => { sweep().catch((err) => log.warn(`[Search] sweep failed: ${(err && err.message) || err}`)); };
+
+ /** The outbox is Inventory's own: started and stopped by server/index.js, not here. */
+ function start() {
+ if (timers.length) return false;
+ 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); }
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
+ }
+
+ async function settle() {
+ if (flushTimer) { clearTimeout(flushTimer); await flush(); }
+ if (flushing) await flushing;
+ }
+
+ /**
+ * Wrap the inventory's definition writes so each names what it changed. → the same object, for chaining. A write
+ * answers { …definition } or { definition, … }; anything else names nothing.
+ */
+ function watch(inv) {
+ for (const m of ['createDefinition', 'updateDefinition', 'updateWorkshopItem', 'review', 'takedown']) {
+ const fn = inv[m];
+ if (typeof fn !== 'function') continue;
+ inv[m] = async (...args) => {
+ const out = await fn(...args);
+ const id = out && (out.definition ? out.definition.id : out.id);
+ if (id) touch(id);
+ return out;
+ };
+ }
+ return inv;
+ }
+
+ return { feed, describe, touch, syncId, sweep, settle, start, stop, watch };
+}
+
+module.exports = { createSearchIndex, describe };
@@ -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,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * Item pages in OpenVibe.Search (server/search-index.js): a sweep puts every published item in the outbox as one
+ * inventory.index_document.upserted, exactly the contract, with its /items/<id> page (which answers); a second sweep
+ * sends nothing; a retired item stays, marked noindex, like its page; a draft is a tombstone, like its 404; the
+ * definition writes name what they changed.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const { s, searchIndex } = t.ctx;
+ const docs = async () => (await s.db.many("SELECT envelope FROM event_outbox WHERE envelope->>'event_type' LIKE 'inventory.index_document.%' 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, 'inventory');
+ return env;
+ };
+ let id;
+
+ try {
+ await check('a sweep sends every published item once, as the contract says, and its page answers', async () => {
+ const published = await s.db.many("SELECT * FROM inventory_definitions WHERE status = 'published' ORDER BY id");
+ assert.ok(published.length > 3, 'the seed published items');
+ const res = await searchIndex.sweep();
+ assert.strictEqual(res.definition.failed, 0);
+ const envs = (await docs()).map(valid);
+ assert.strictEqual(envs.length, published.length);
+ id = published[0].id;
+ const doc = envs.find((e) => e.payload.id === id).payload;
+ assert.strictEqual(doc.canonical_url, `${t.config.baseUrl}/items/${id}`);
+ assert.ok(doc.title.startsWith(`${published[0].name} · `));
+ assert.strictEqual(doc.facets.rarity, published[0].rarity);
+ assert.deepStrictEqual(doc.indexability, { decision: 'index', reasons: [] });
+ assert.strictEqual((await t.get(`/items/${id}`)).status, 200);
+ assert.strictEqual((await searchIndex.sweep()).definition.sent, 0, 'a second sweep sends nothing');
+ });
+
+ await check('a retired item stays in Search marked noindex; a draft is a tombstone', async () => {
+ await s.db.exec("UPDATE inventory_definitions SET status = 'retired' WHERE id = $1", [id]);
+ await searchIndex.syncId(id);
+ let env = valid((await docs()).pop());
+ assert.strictEqual(env.event_type, 'inventory.index_document.upserted');
+ assert.strictEqual(env.payload.indexability.decision, 'noindex');
+ await s.db.exec("UPDATE inventory_definitions SET status = 'draft' WHERE id = $1", [id]);
+ await searchIndex.syncId(id);
+ env = valid((await docs()).pop());
+ assert.strictEqual(env.event_type, 'inventory.index_document.deleted');
+ assert.strictEqual((await t.get(`/items/${id}`)).status, 404);
+ await s.db.exec("UPDATE inventory_definitions SET status = 'published' WHERE id = $1", [id]);
+ });
+
+ await check('the definition writes name what they changed, and the change is synced', async () => {
+ // watch() wraps createDefinition, updateDefinition, updateWorkshopItem, review and takedown; a write answers
+ // the definition ({ id }) or { definition }, and that id is synced a moment later.
+ await s.db.exec("UPDATE inventory_definitions SET description = 'Burns brighter.' WHERE id = $1", [id]);
+ const before = (await docs()).length;
+ const wrapped = searchIndex.watch({ review: async () => ({ id }), takedown: async () => ({ definition: { id }, revoked: 0 }), grant: async () => ({ id: 'not-a-definition-write' }) });
+ await wrapped.review();
+ await searchIndex.settle();
+ const after = await docs();
+ assert.strictEqual(after.length, before + 1, 'the touched item was synced once');
+ assert.match(valid(after[after.length - 1]).payload.summary, /Burns brighter/);
+ await wrapped.takedown();
+ await wrapped.grant();
+ await searchIndex.settle();
+ assert.strictEqual((await docs()).length, before + 1, 'unchanged since: nothing more; grant is not wrapped');
+ });
+ } finally {
+ await done(t);
+ }
+})();