openvibers/openvibe.inventory Commits
Page stacked inventory holdings with stable cursors (#246)
Changes
@@@ -103,13 +103,28 @@ every other route stays same-site.| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds || `GET /definitions?kind=&issuer=`, `/definitions/:id` | `inventory.item.read` (public) | published items || `GET /people/:subject/items`, `/people/:subject/equipped` | `inventory.item.read` (public) | a person's owned items and what they wear |++| `GET /people/:subject/holdings`, `/people/:subject/holdings/count` | `inventory.item.read` (public) | stacked, filtered public holdings and their count || `GET /equipped?subjects=a,b,…` | `inventory.item.read` (public) | up to 100 equipped sets in one call (chat renders) || `GET /me/items` | `inventory.item.list` | the caller's own items, every state |++| `GET /me/holdings`, `/me/holdings/count` | `inventory.item.list` | the caller's stacked owned holdings and their count || `PUT /me/equipped` | `inventory.equip.manage` | equip or clear a slot || `POST /grants` | `inventory.item.grant` | an issuer grants an instance (idempotent per key; 409 `inventory.supply_exhausted`) || `POST /instances/:id/consume`, `/revoke` | `inventory.item.consume` | an issuer uses up or takes back an instance || `POST /definitions`, `PATCH /definitions/:id` | `inventory.definition.manage` | an issuer's items |++Holdings default to 60 stacks per page (maximum 200). Filters are `issuer` or `site` (a service name), `kind`,++`rarity`, `equipped=true|false`, `tradable=true|false`, and `text` (a substring of the name). Sort with++`sort=newest|name|rarity|count`. Pass `next_cursor` as `cursor` with the same filters and sort. The cursor pins the++newest instance seen on the first page, so later grants do not move stacks during paging. `tradable=true` currently++returns an empty set because Inventory has no trade or transfer operation. The count endpoint counts stacks after++the same filters. The original `/items` routes still return individual instances, including non-owned states on `/me`.++++For example, `GET /api/v1/me/holdings?kind=live.hat&limit=1` answers:++++```json++{"subject":"usr_…","holdings":[{"definition":{"id":"itd_…","kind":"live.hat","name":"Crown","rarity":"epic"},"instance":{"id":"inv_…","definition_id":"itd_…","state":"owned"},"count":3,"equipped":true,"tradable":false,"kind_slots":["hat"]}],"next_cursor":"…"}++```++Tokens are for audience `openvibe.inventory` (Network's rule: `openvibe.<service>`). A person acts for themself (theirtoken or this site's session; a cookie write must come from this site). A serviceacting for a person sends `X-OV-Subject: usr_…` and holds the route's capability. A public read needs no token; annew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- The common per-person owned scan and stable newest boundary; the existing owner index++-- still serves reads that include consumed and revoked instances.++CREATE INDEX inventory_instances_owned_page ON inventory_instances (owner_subject, acquired_at DESC, id DESC)++ INCLUDE (definition_id) WHERE state = 'owned';++CREATE INDEX inventory_instances_owned_stack ON inventory_instances (owner_subject, definition_id, acquired_at DESC, id DESC)++ WHERE state = 'owned';++CREATE INDEX inventory_definitions_name_sort ON inventory_definitions ((lower(name)) COLLATE "C", id);nnew file mode 100644@@@ -0,0 +1,78 @@++(() => {++ 'use strict';++ const marker = document.getElementById('inventory-next');++ const root = document.getElementById('inventory-holdings');++ if (!marker || !root || !('IntersectionObserver' in window)) return;++ const own = marker.dataset.own === 'true';++ const kindNames = JSON.parse(root.dataset.kindNames || '{}');++ const itemPath = (id) => `${location.pathname.startsWith('/inventory/') ? '/inventory' : ''}/items/${encodeURIComponent(id)}`;++ const pagePath = (path) => `${location.pathname.startsWith('/inventory/') ? '/inventory' : ''}${path}`;++ const el = (tag, className, value) => {++ const node = document.createElement(tag);++ if (className) node.className = className;++ if (value != null) node.textContent = value;++ return node;++ };++ function artwork(d) {++ const box = el('span', `inv-art inv-art-md r-${d.rarity}`);++ if (d.art && d.art.media_id) {++ const img = el('img');++ img.src = `https://openvibe.media/o/${encodeURIComponent(d.art.media_id)}?w=128&fmt=webp`;++ img.alt = ''; img.loading = 'lazy'; img.width = 64; img.height = 64;++ box.append(img);++ } else box.textContent = d.art && d.art.emoji || '◆';++ return box;++ }++ function tile(h) {++ const d = h.definition;++ const li = el('li', own ? `inv-tile r-${d.rarity}${h.equipped ? ' is-worn' : ''}` : `inv-card r-${d.rarity}${h.equipped ? ' is-worn' : ''}`);++ const label = document.createDocumentFragment();++ label.append(artwork(d), el('span', own ? 'inv-tile-name' : 'inv-card-name', d.name));++ label.append(el('span', own ? 'inv-tile-sub' : 'inv-rarity', `${d.rarity}${h.count > 1 ? ` · ×${h.count}` : ''}`));++ if (own && h.kind_slots.length) {++ const form = el('form', 'inv-tile-form');++ form.method = 'post'; form.action = pagePath('/me/equip');++ for (const [name, value] of Object.entries({ kind: d.kind, slot: h.kind_slots[0], instance_id: h.equipped ? '' : h.instance.id })) {++ const input = el('input'); input.type = 'hidden'; input.name = name; input.value = value; form.append(input);++ }++ const button = el('button', 'inv-tile-main'); button.type = 'submit';++ button.setAttribute('aria-label', `${h.equipped ? 'Take off' : 'Wear'} ${d.name}`);++ button.append(label, el('span', 'inv-tile-act', h.equipped ? 'Wearing' : 'Wear'));++ form.append(button); li.append(form);++ const info = el('a', 'inv-tile-info', 'i'); info.href = itemPath(d.id); info.setAttribute('aria-label', `About ${d.name}`); li.append(info);++ } else {++ const a = el('a', own ? 'inv-tile-main' : 'inv-card-link'); a.href = itemPath(d.id); a.append(label); li.append(a);++ }++ return li;++ }++ function append(h) {++ const key = h.definition.kind.replace(/\W/g, '-');++ let section = own ? document.getElementById(`k-${key}`) : [...root.children].find((s) => s.dataset.kind === h.definition.kind);++ if (!section) {++ section = el('section', 'inv-kind'); section.dataset.kind = h.definition.kind;++ if (own) section.id = `k-${key}`;++ section.append(el('h2', '', kindNames[h.definition.kind] || h.definition.kind)); root.append(section);++ }++ let list = section.querySelector(own ? 'ul.inv-tiles' : 'ul.inv-grid');++ if (!list) { list = el('ul', own ? 'inv-tiles' : 'inv-grid'); section.append(list); }++ list.append(tile(h));++ }++ let busy = false;++ const observer = new IntersectionObserver(async (entries) => {++ if (!entries.some((e) => e.isIntersecting) || busy || !marker.dataset.cursor) return;++ busy = true;++ try {++ const target = new URL(marker.dataset.endpoint, location.href);++ target.searchParams.set('cursor', marker.dataset.cursor);++ const response = await fetch(target, { credentials: 'same-origin' });++ if (!response.ok) throw new Error(`HTTP ${response.status}`);++ const result = await response.json();++ for (const holding of result.holdings) append(holding);++ marker.dataset.cursor = result.next_cursor || '';++ if (!result.next_cursor) { observer.disconnect(); marker.remove(); }++ } catch {++ marker.textContent = 'More items could not load. Scroll to try again.';++ } finally { busy = false; }++ }, { rootMargin: '600px' });++ observer.observe(marker);++})();@@@ -246,7 +246,7 @@ async function createApp(opts = {}) {// credentials (openvibe-shared/items.js draws what people wear with them, on every OpenVibe site). Everything else// keeps the same-site resource policy and no CORS. A preflight is answered here.app.use('/api/v1', require('./http/wallet-cors'));-- const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped))$/;++ const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped|holdings(?:\/count)?))$/;app.use('/api/v1', (req, res, next) => {if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();res.setHeader('Access-Control-Allow-Origin', '*');@@@ -71,6 +71,11 @@ function createApi(ctx) {r.get('/people/:subject/items', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json(await inv.inventory(req.params.subject, {cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null,}))));++ const holdingQuery = (req) => ({ ...req.query, cursor: req.query.cursor || null });++ r.get('/people/:subject/holdings/count', read, limits.reads('inventory.item.read'), wrap(async (req, res) =>++ res.json(await inv.holdings.count(req.params.subject, { ...req.query, own: false }))));++ r.get('/people/:subject/holdings', read, limits.reads('inventory.item.read'), wrap(async (req, res) =>++ res.json(await inv.holdings.page(req.params.subject, { ...holdingQuery(req), own: false }))));r.get('/people/:subject/equipped', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {if (!SUBJECT_RE.test(req.params.subject)) return http.sendProblem(res, 404, 'inventory.unknown_subject', { detail: 'a usr_ subject', ctx: req.ov });return res.json((await inv.equipped([req.params.subject]))[0]);@@@ -87,6 +92,14 @@ function createApi(ctx) {if (!subject) return undefined;return res.json(await inv.inventory(subject, { own: true, cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null }));}));++ r.get('/me/holdings/count', principal.requireCapability('inventory.item.list'), limits.reads('inventory.item.list'), wrap(async (req, res) => {++ const subject = personOf(req, res);++ if (subject) res.json(await inv.holdings.count(subject, { ...req.query, own: true }));++ }));++ r.get('/me/holdings', principal.requireCapability('inventory.item.list'), limits.reads('inventory.item.list'), wrap(async (req, res) => {++ const subject = personOf(req, res);++ if (subject) res.json(await inv.holdings.page(subject, { ...holdingQuery(req), own: true }));++ }));r.put('/me/equipped', principal.requireCapability('inventory.equip.manage'), limits.budget('inventory.equip'), wrap(async (req, res) => {const subject = personOf(req, res);if (!subject) return undefined;@@@ -45,7 +45,7 @@ function earnHow(d) {/** The item's picture: its emoji, or a Media image, in a square tile tinted by rarity. */function art(d, { size = 'md' } = {}) {const a = d.art || {};-- if (a.media_id) return html`<span class="inv-art inv-art-${size} r-${d.rarity}"><img src="https://openvibe.media/o/${a.media_id}" alt="" loading="lazy" width="64" height="64"></span>`;++ if (a.media_id) return html`<span class="inv-art inv-art-${size} r-${d.rarity}"><img src="https://openvibe.media/o/${a.media_id}?w=${size === 'xl' ? 512 : size === 'sm' ? 64 : 128}&fmt=webp" alt="" loading="lazy" width="64" height="64"></span>`;return html`<span class="inv-art inv-art-${size} r-${d.rarity}" aria-hidden="true">${a.emoji || '◆'}</span>`;}const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;@@@ -225,25 +225,26 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm});// ── Your inventory ───────────────────────────────────────-- async function ownedView(subject, { own }) {-- const [list, eq, kinds] = await Promise.all([inv.inventory(subject, { own, limit: 200 }), inv.equipped([subject]), inv.listKinds()]);-- const worn = new Set(Object.values(eq[0] ? eq[0].slots : {}).map((x) => x.instance_id));-- const owned = list.instances.filter((i) => i.state === 'owned');++ async function ownedView(subject, { own, cursor = null }) {++ const [list, eq, kinds, total] = await Promise.all([inv.holdings.page(subject, { own, cursor }), inv.equipped([subject]), inv.listKinds(), inv.holdings.count(subject, { own })]);++ const worn = new Set([...Object.values(eq[0] ? eq[0].slots : {}).map((x) => x.instance_id),++ ...list.holdings.filter((h) => h.equipped).map((h) => h.instance.id)]);++ const owned = list.holdings.map((h) => h.instance);const byKind = new Map(kinds.map((k) => [k.id, []]));-- for (const i of owned) { const d = list.definitions[i.definition_id]; if (d && byKind.has(d.kind)) byKind.get(d.kind).push({ i, d }); }-- return { kinds, byKind, worn, owned, more: !!list.next_cursor };++ for (const h of list.holdings) { const { instance: i, definition: d } = h; if (byKind.has(d.kind)) byKind.get(d.kind).push({ i, d, count: h.count }); }++ return { kinds, byKind, worn, owned, more: !!list.next_cursor, cursor: list.next_cursor, total: total.count };}async function ownPage(req, res) {const me = signedIn(req);if (!me) return res.redirect(303, '/auth/login?next=%2Fme');-- const { kinds, byKind, worn, owned, more } = await ownedView(me, { own: true });++ const { kinds, byKind, worn, owned, more, cursor, total } = await ownedView(me, { own: true, cursor: req.query.cursor || null });const done = req.query.done ? String(req.query.done) : null;const error = req.query.error ? String(req.query.error).slice(0, 200) : null;page(req, res, {title: 'Your inventory',body: html`<h1>Your inventory</h1>--<p class="lede">${owned.length ? `${plural(owned.length, 'item', 'items')}. What you wear shows in chat, on stream overlays and on your profile, on every OpenVibe site.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>++<p class="lede">${total ? `${plural(total, 'item', 'items')}. What you wear shows in chat, on stream overlays and on your profile, on every OpenVibe site.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>${done ? html`<p class="notice ok" role="status">${done === 'equipped' ? 'Equipped. It shows everywhere that kind is shown.' : 'Taken off.'}</p>` : ''}${error ? html`<p class="notice bad" role="alert">${error}</p>` : ''}${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-h"><h2 id="inv-wearing-h" class="sr-only">What you wear</h2>@@@ -252,17 +253,17 @@ ${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-return html`<li><a class="inv-slot${on ? '' : ' is-empty'}" href="#k-${k.id.replace(/\W/g, '-')}">${on ? art(on.d, { size: 'sm' }) : html`<span class="inv-art inv-art-sm inv-art-none" aria-hidden="true"></span>`}<span class="inv-slot-t"><small>${k.name}</small><b>${on ? on.d.name : 'Nothing worn'}</b></span></a></li>`;})}</ul></section>` : ''}<p class="inv-links"><a href="${url(`/u/${me}`)}">Your public page</a> · <a href="${url(`/items`)}">Every item</a></p>--${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}">-- <h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}${k.slots.length ? ' · tap one to wear it' : ''}</span></h2>-- ${tiles(byKind.get(k.id), ({ i, d }) => tile(d, {++<div id="inventory-holdings" data-kind-names="${JSON.stringify(Object.fromEntries(kinds.map((k) => [k.id, k.name])))}">${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}">++ <h2>${k.name} <span class="muted small">${k.slots.length ? 'tap one to wear it' : ''}</span></h2>++ ${tiles(byKind.get(k.id), ({ i, d, count }) => tile(d, {worn: worn.has(i.id),-- note: i.serial ? `#${i.serial}` : '',++ note: count > 1 ? `×${count}` : i.serial ? `#${i.serial}` : '',form: k.slots.length ? (label) => html`<form method="post" action="${url(`/me/equip`)}" class="inv-tile-form"><input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}"><input type="hidden" name="instance_id" value="${worn.has(i.id) ? '' : i.id}"><button type="submit" class="inv-tile-main" aria-label="${worn.has(i.id) ? `Take off ${d.name}` : `Wear ${d.name}`}">${label}<span class="inv-tile-act">${worn.has(i.id) ? 'Wearing' : 'Wear'}</span></button></form>` : null,}), { first: 24, more: `More ${k.name.toLowerCase()} items` })}--</section>`)}--${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="${url(`/api/v1/me/items`)}">/api/v1/me/items</a>.</p>` : ''}`,++</section>`)}</div>++${more ? html`<div id="inventory-next" data-endpoint="${url('/api/v1/me/holdings')}" data-cursor="${cursor}" data-own="true"><a href="${url(`/me?cursor=${encodeURIComponent(cursor)}`)}">Next items</a></div><script src="${url('/holdings.js')}" defer></script>` : ''}`,});}r.get('/me', ownPage);@@@ -285,17 +286,18 @@ ${more ? html`<p class="muted">Showing the newest 200. The full list is at <a hrr.get('/u/:subject', async (req, res) => {const subject = String(req.params.subject);if (!SUBJECT_RE.test(subject)) return notFound(req, res, 'There is no such person.');-- const { kinds, byKind, worn, owned } = await ownedView(subject, { own: false });++ const { kinds, byKind, worn, owned, more, cursor, total } = await ownedView(subject, { own: false, cursor: req.query.cursor || null });const mine = signedIn(req) === subject;const wornItems = owned.filter((i) => worn.has(i.id));page(req, res, {index: false, cache: signedIn(req) ? null : PUBLIC_CACHE,title: mine ? 'Your public inventory' : 'An OpenVibe inventory',body: html`<h1>${mine ? 'Your public inventory' : 'An OpenVibe inventory'}</h1>--<p class="lede">${plural(owned.length, 'item', 'items')}${wornItems.length ? `, ${wornItems.length} worn` : ''}.${mine ? ' This is what anyone sees.' : ''}</p>--${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind"><h2>${k.name}</h2>-- <ul class="inv-grid">${byKind.get(k.id).map(({ i, d }) => card(d, { worn: worn.has(i.id), extra: worn.has(i.id) ? html`<p class="inv-worn">Wearing</p>` : '' }))}</ul></section>`)}--${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,++<p class="lede">${plural(total, 'item', 'items')}${wornItems.length ? `, ${wornItems.length} worn` : ''}.${mine ? ' This is what anyone sees.' : ''}</p>++<div id="inventory-holdings" data-kind-names="${JSON.stringify(Object.fromEntries(kinds.map((k) => [k.id, k.name])))}">${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" data-kind="${k.id}"><h2>${k.name}</h2>++ <ul class="inv-grid">${byKind.get(k.id).map(({ i, d, count }) => card(d, { worn: worn.has(i.id), owned: count > 1 ? `×${count}` : null, extra: worn.has(i.id) ? html`<p class="inv-worn">Wearing</p>` : '' }))}</ul></section>`)}</div>++${!total ? html`<p class="muted">Nothing to show yet.</p>` : ''}++${more ? html`<div id="inventory-next" data-endpoint="${url(`/api/v1/people/${subject}/holdings`)}" data-cursor="${cursor}"><a href="${url(`/u/${subject}?cursor=${encodeURIComponent(cursor)}`)}">Next items</a></div><script src="${url('/holdings.js')}" defer></script>` : ''}`,});});nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++++// Stacked, snapshot-bounded reads. The boundary is the newest instance present on page one;++// grants made during scrolling cannot move an existing stack or create a new one in later pages.++const { InventoryError, SUBJECT_RE, RARITY } = require('./store');++const SORTS = new Set(['newest', 'name', 'rarity', 'count']);++const bad = (detail) => { throw new InventoryError(400, 'inventory.bad_holdings_query', detail); };++const cursorError = () => { throw new InventoryError(400, 'inventory.bad_cursor', 'cursor does not match these filters and sort'); };++++function optionsOf(input = {}) {++ const sort = String(input.sort || 'newest');++ if (!SORTS.has(sort)) bad('sort must be newest, name, rarity or count');++ const limit = input.limit == null ? 60 : Number(input.limit);++ if (!Number.isInteger(limit) || limit < 1 || limit > 200) bad('limit must be from 1 to 200');++ const equipped = input.equipped == null ? null : String(input.equipped);++ const tradable = input.tradable == null ? null : String(input.tradable);++ if (equipped != null && !['true', 'false'].includes(equipped)) bad('equipped must be true or false');++ if (tradable != null && !['true', 'false'].includes(tradable)) bad('tradable must be true or false');++ const issuer = input.issuer ? String(input.issuer) : input.site ? `service:${String(input.site)}` : null;++ const filter = { issuer, kind: input.kind ? String(input.kind) : null, rarity: input.rarity ? String(input.rarity) : null,++ equipped, tradable, text: input.text ? String(input.text).trim().slice(0, 100).toLowerCase() : null, sort };++ if (filter.rarity && !RARITY.includes(filter.rarity)) bad('unknown rarity');++ return { filter, limit };++}++++function createHoldings(db) {++ function conditions(subject, own, f, upper) {++ const p = [subject];++ const bind = (v) => { p.push(v); return `$${p.length}`; };++ const where = [`i.owner_subject = $1`, `i.state = 'owned'`];++ if (!own) where.push("d.status IN ('published', 'retired')");++ if (upper) where.push(`(i.acquired_at, i.id) <= (${bind(upper.at)}, ${bind(upper.id)})`);++ if (f.issuer) where.push(`d.issuer = ${bind(f.issuer)}`);++ if (f.kind) where.push(`d.kind = ${bind(f.kind)}`);++ if (f.rarity) where.push(`d.rarity = ${bind(f.rarity)}`);++ if (f.text) where.push(`position(${bind(f.text)} in lower(d.name)) > 0`);++ if (f.equipped) where.push(`${f.equipped === 'true' ? '' : 'NOT '}EXISTS (SELECT 1 FROM inventory_equipped e++ JOIN inventory_instances ei ON ei.id = e.instance_id WHERE e.owner_subject = i.owner_subject AND ei.definition_id = i.definition_id)`);++ // Inventory has no trade or transfer operation; every holding is currently non-tradable.++ if (f.tradable === 'true') where.push('false');++ return { p, bind, where: where.join(' AND ') };++ }++++ async function count(subject, { own = false, ...input } = {}) {++ if (!SUBJECT_RE.test(String(subject))) throw new InventoryError(404, 'inventory.unknown_subject', 'a usr_ subject');++ const { filter } = optionsOf(input);++ const q = conditions(subject, own, filter, null);++ const total = await db.value(`SELECT count(DISTINCT i.definition_id) FROM inventory_instances i++ JOIN inventory_definitions d ON d.id = i.definition_id WHERE ${q.where}`, q.p);++ return { subject, count: Number(total) };++ }++++ async function page(subject, { own = false, cursor = null, ...input } = {}) {++ if (!SUBJECT_RE.test(String(subject))) throw new InventoryError(404, 'inventory.unknown_subject', 'a usr_ subject');++ const { filter, limit } = optionsOf(input);++ const signature = JSON.stringify([subject, own, filter]);++ let state;++ if (cursor) {++ try { state = JSON.parse(Buffer.from(String(cursor), 'base64url').toString()); } catch { cursorError(); }++ if (!state || state.signature !== signature || typeof state.key !== 'string' || typeof state.id !== 'string' ||++ !state.upper || typeof state.upper.at !== 'string' || typeof state.upper.id !== 'string') cursorError();++ }++ const upper = state ? state.upper : await db.maybe(`SELECT acquired_at AS at, id FROM inventory_instances++ WHERE owner_subject = $1 AND state = 'owned' ORDER BY acquired_at DESC, id DESC LIMIT 1`, [subject]);++ if (!upper) return { subject, holdings: [], next_cursor: null };++ const q = conditions(subject, own, filter, upper);++ const key = { newest: "max(i.acquired_at || '|' || i.id)", name: 'lower(d.name)', rarity: "CASE d.rarity WHEN 'common' THEN '0' WHEN 'uncommon' THEN '1' WHEN 'rare' THEN '2' WHEN 'epic' THEN '3' ELSE '4' END", count: "lpad(count(*)::text, 12, '0')" }[filter.sort];++ const direction = filter.sort === 'name' ? 'ASC' : 'DESC';++ const rows = filter.sort === 'newest' ? await db.many(`WITH heads AS MATERIALIZED (++ SELECT i.*, d.kind, d.name, d.description, d.art, d.rarity, d.issuer, d.status,++ k.slots AS kind_slots, i.id AS representative_id, (i.acquired_at || '|' || i.id) COLLATE "C" AS sort_key,++ EXISTS (SELECT 1 FROM inventory_equipped e JOIN inventory_instances ei ON ei.id = e.instance_id++ WHERE e.owner_subject = i.owner_subject AND ei.definition_id = i.definition_id) AS equipped++ FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id++ JOIN inventory_kinds k ON k.id = d.kind++ WHERE ${q.where}++ AND NOT EXISTS (SELECT 1 FROM inventory_instances newer WHERE newer.owner_subject = i.owner_subject++ AND newer.definition_id = i.definition_id AND newer.state = 'owned'++ AND (newer.acquired_at, newer.id) > (i.acquired_at, i.id)++ AND (newer.acquired_at, newer.id) <= (${q.bind(upper.at)}, ${q.bind(upper.id)}))++ ${state ? `AND (i.acquired_at || '|' || i.id, i.definition_id) < (${q.bind(state.key)}, ${q.bind(state.id)})` : ''}++ ORDER BY i.acquired_at DESC, i.id DESC LIMIT ${q.bind(limit + 1)}++ ) SELECT h.*, (SELECT count(*)::int FROM inventory_instances si WHERE si.owner_subject = h.owner_subject++ AND si.definition_id = h.definition_id AND si.state = 'owned'++ AND (si.acquired_at, si.id) <= (${q.bind(upper.at)}, ${q.bind(upper.id)})) AS quantity++ FROM heads h ORDER BY h.acquired_at DESC, h.id DESC`, q.p) : await db.many(`WITH groups AS (++ SELECT i.definition_id, count(*)::int AS quantity, ${key} COLLATE "C" AS sort_key,++ (array_agg(i.id ORDER BY i.acquired_at DESC, i.id DESC))[1] AS representative_id++ FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id++ WHERE ${q.where} GROUP BY i.definition_id, d.name, d.rarity++ ) SELECT g.*, i.*, d.*, k.slots AS kind_slots,++ EXISTS (SELECT 1 FROM inventory_equipped e JOIN inventory_instances ei ON ei.id = e.instance_id++ WHERE e.owner_subject = i.owner_subject AND ei.definition_id = i.definition_id) AS equipped++ FROM groups g JOIN inventory_instances i ON i.id = g.representative_id++ JOIN inventory_definitions d ON d.id = g.definition_id++ JOIN inventory_kinds k ON k.id = d.kind++ ${state ? `WHERE (g.sort_key, g.definition_id) ${direction === 'ASC' ? '>' : '<'} (${q.bind(state.key)}, ${q.bind(state.id)})` : ''}++ ORDER BY g.sort_key ${direction}, g.definition_id ${direction} LIMIT ${q.bind(limit + 1)}`, q.p);++ const shown = rows.slice(0, limit);++ return { subject, holdings: shown.map((r) => ({++ definition: { id: r.definition_id, kind: r.kind, name: r.name, description: r.description || undefined,++ art: r.art, rarity: r.rarity, issuer: r.issuer, status: r.status },++ instance: { id: r.representative_id, definition_id: r.definition_id, owner: subject,++ state: 'owned', acquired_at: r.acquired_at, ...(r.serial == null ? {} : { serial: Number(r.serial) }) },++ count: r.quantity, equipped: r.equipped, tradable: false, kind_slots: r.kind_slots,++ })), next_cursor: rows.length > limit ? Buffer.from(JSON.stringify({ signature, upper, key: shown.at(-1).sort_key, id: shown.at(-1).definition_id })).toString('base64url') : null };++ }++ return { page, count };++}++++module.exports = { createHoldings };@@@ -71,6 +71,7 @@ function decodeCursor(c) {function createInventory({ s, outbox, log = console }) {const db = s.db;++ const holdings = require('./holdings').createHoldings(db);const ajv = new Ajv({ strict: false, allErrors: false });const validators = new Map(); // kind id + updated_at → compiled attribute schema@@@ -470,7 +471,7 @@ function createInventory({ s, outbox, log = console }) {return {listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,-- grant, consume, inventory, equipped, equip, ledger, review, workshopItems, takedown, checkWorkshopQuota,++ grant, consume, inventory, holdings, equipped, equip, ledger, review, workshopItems, takedown, checkWorkshopQuota,};}nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++const assert = require('assert');++const { performance } = require('perf_hooks');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const person = t.network.addUser('holdings-reader');++ const db = t.ctx.s.db;++ const api = (path) => t.get(`/api/v1${path}`, { as: person });++ try {++ // One statement per table keeps fixture setup separate from the measured read.++ await db.exec(`INSERT INTO inventory_definitions++ (id, kind, name, art, rarity, attributes, issuer, supply_issued, status, created_at, updated_at)++ SELECT 'itd_' || lpad(n::text, 26, '0'), 'live.hat', 'Hat ' || lpad(n::text, 5, '0'), '{"emoji":"◆"}'::jsonb,++ CASE WHEN n % 2 = 0 THEN 'rare' ELSE 'common' END, '{}'::jsonb,++ CASE WHEN n % 3 = 0 THEN 'service:quest' ELSE 'service:live' END, 1, 'published',++ '2026-11-10T00:00:00Z', '2026-11-10T00:00:00Z' FROM generate_series(1, 5000) n`);++ await db.exec(`INSERT INTO inventory_instances++ (id, definition_id, owner_subject, origin, state, attributes, acquired_at, updated_at)++ SELECT 'inv_' || lpad(n::text, 26, '0'), 'itd_' || lpad(n::text, 26, '0'), $1,++ 'earned', 'owned', '{}'::jsonb, '2026-11-10T00:00:00Z', '2026-11-10T00:00:00Z'++ FROM generate_series(1, 5000) n`, [person.subject]);++ await db.exec(`INSERT INTO inventory_instances++ (id, definition_id, owner_subject, origin, state, attributes, acquired_at, updated_at)++ VALUES ('inv_' || lpad('5001', 26, '0'), 'itd_' || lpad('5000', 26, '0'), $1,++ 'earned', 'owned', '{}'::jsonb, '2026-11-10T00:00:01Z', '2026-11-10T00:00:01Z')`, [person.subject]);++ await db.exec('ANALYZE inventory_instances');++ await db.exec(`INSERT INTO inventory_equipped (owner_subject, kind, slot, instance_id, updated_at)++ VALUES ($1, 'live.hat', 'hat', 'inv_' || lpad('5000', 26, '0'), '2026-11-10T00:00:02Z')`, [person.subject]);++ await check('stack count, count route, filters and sorts are SQL-backed', async () => {++ const first = (await api('/me/holdings?limit=2')).json();++ assert.strictEqual(first.holdings[0].count, 2);++ assert.strictEqual(first.holdings[0].equipped, true, 'another instance in the stack is equipped');++ assert.strictEqual((await api('/me/holdings/count')).json().count, 5000);++ assert.strictEqual((await api('/me/holdings/count?rarity=rare')).json().count, 2500);++ assert.strictEqual((await api('/me/holdings/count?issuer=service%3Aquest')).json().count, 1666);++ assert.strictEqual((await api('/me/holdings/count?site=quest')).json().count, 1666);++ assert.strictEqual((await api('/me/holdings/count?kind=live.hat')).json().count, 5000);++ assert.strictEqual((await api('/me/holdings/count?equipped=true')).json().count, 1);++ assert.strictEqual((await api('/me/holdings/count?equipped=false')).json().count, 4999);++ assert.strictEqual((await api('/me/holdings/count?text=05000')).json().count, 1);++ assert.strictEqual((await api('/me/holdings/count?tradable=true')).json().count, 0);++ assert.strictEqual((await api('/me/holdings?sort=count&limit=1')).json().holdings[0].count, 2);++ assert.strictEqual((await api('/me/holdings?sort=name&limit=1')).json().holdings[0].definition.name, 'Hat 00001');++ assert.strictEqual((await api('/me/holdings?sort=rarity&limit=1')).json().holdings[0].definition.rarity, 'rare');++ assert.strictEqual((await api('/me/holdings?limit=201')).status, 400);++ });++ await check('cursor holds a stable boundary while new items arrive', async () => {++ const a = (await api('/me/holdings?limit=2')).json();++ const byCount = (await api('/me/holdings?sort=count&limit=1')).json();++ await db.exec(`INSERT INTO inventory_instances++ (id, definition_id, owner_subject, origin, state, attributes, acquired_at, updated_at)++ VALUES ('inv_' || lpad('5002', 26, '0'), 'itd_' || lpad('4999', 26, '0'), $1,++ 'earned', 'owned', '{}'::jsonb, '2026-11-10T00:00:02Z', '2026-11-10T00:00:02Z')`, [person.subject]);++ const response = await api(`/me/holdings?limit=2&cursor=${encodeURIComponent(a.next_cursor)}`);++ assert.strictEqual(response.status, 200, `${response.text} ${t.logs().slice(-1000)}`);++ const b = response.json();++ assert.strictEqual(b.holdings.length, 2);++ assert(!b.holdings.some((h) => a.holdings.some((prior) => prior.definition.id === h.definition.id)));++ assert.strictEqual((await api(`/me/holdings?limit=2&sort=name&cursor=${encodeURIComponent(a.next_cursor)}`)).status, 400);++ const nextCount = (await api(`/me/holdings?sort=count&limit=1&cursor=${encodeURIComponent(byCount.next_cursor)}`)).json();++ assert.strictEqual(nextCount.holdings[0].count, 1, 'new grant does not change the count sort snapshot');++ });++ await check('public query cannot request private holdings', async () => {++ await db.exec("UPDATE inventory_definitions SET status = 'draft' WHERE id = 'itd_' || lpad('4998', 26, '0')");++ const path = `/people/${person.subject}/holdings`;++ assert.strictEqual((await api(`${path}/count?own=true`)).json().count, 4999);++ assert.strictEqual((await api(`${path}?own=true&text=04998`)).json().holdings.length, 0);++ assert.strictEqual((await api('/me/holdings/count')).json().count, 5000);++ });++ await check('pages render a first batch and a next-page link', async () => {++ const mine = await t.get('/me', { as: person });++ assert.strictEqual(mine.status, 200, mine.text.slice(-300));++ assert.match(mine.text, /id="inventory-next"/);++ assert.match(mine.text, /data-endpoint="\/api\/v1\/me\/holdings"/);++ assert.match(mine.text, /Next items/);++ const publicPage = await t.get(`/u/${person.subject}`);++ assert.strictEqual(publicPage.status, 200);++ assert.match(publicPage.text, /data-endpoint="\/api\/v1\/people\/.+\/holdings"/);++ });++ await check('5,000 holdings return a warmed page in under 50 ms on PGlite', async () => {++ await t.ctx.inv.holdings.page(person.subject, { own: true });++ const start = performance.now();++ const page = await t.ctx.inv.holdings.page(person.subject, { own: true });++ const ms = performance.now() - start;++ assert.strictEqual(page.holdings.length, 60);++ if (process.env.INVENTORY_TEST_STORE !== 'pg') assert(ms < 50, `PGlite page took ${ms.toFixed(1)} ms`);++ process.stdout.write(`holdings page (${process.env.INVENTORY_TEST_STORE === 'pg' ? 'PostgreSQL' : 'PGlite'}): ${ms.toFixed(1)} ms\n`);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -13,7 +13,7 @@ const { boot, check, done } = require('./helpers/boot');const origin = { origin: 'https://openvibe.community' };try {await check('the public reads answer every origin', async () => {-- for (const p of ['/api/v1/kinds', '/api/v1/kinds/live.hat', '/api/v1/definitions?kind=live.hat', `/api/v1/equipped?subjects=${A}`, `/api/v1/people/${A}/items`, `/api/v1/people/${A}/equipped`]) {++ for (const p of ['/api/v1/kinds', '/api/v1/kinds/live.hat', '/api/v1/definitions?kind=live.hat', `/api/v1/equipped?subjects=${A}`, `/api/v1/people/${A}/items`, `/api/v1/people/${A}/holdings`, `/api/v1/people/${A}/holdings/count`, `/api/v1/people/${A}/equipped`]) {const r = await t.get(p, { headers: origin });assert.strictEqual(r.status, 200, p);assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', p);
@@ -103,13 +103,28 @@ every other route stays same-site.
@@ -103,13 +103,28 @@ every other route stays same-site.
| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds |
| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds |
| `GET /definitions?kind=&issuer=`, `/definitions/:id` | `inventory.item.read` (public) | published items |
| `GET /definitions?kind=&issuer=`, `/definitions/:id` | `inventory.item.read` (public) | published items |
| `GET /people/:subject/items`, `/people/:subject/equipped` | `inventory.item.read` (public) | a person's owned items and what they wear |
| `GET /people/:subject/items`, `/people/:subject/equipped` | `inventory.item.read` (public) | a person's owned items and what they wear |
+| `GET /people/:subject/holdings`, `/people/:subject/holdings/count` | `inventory.item.read` (public) | stacked, filtered public holdings and their count |
| `GET /equipped?subjects=a,b,…` | `inventory.item.read` (public) | up to 100 equipped sets in one call (chat renders) |
| `GET /equipped?subjects=a,b,…` | `inventory.item.read` (public) | up to 100 equipped sets in one call (chat renders) |
| `GET /me/items` | `inventory.item.list` | the caller's own items, every state |
| `GET /me/items` | `inventory.item.list` | the caller's own items, every state |
+| `GET /me/holdings`, `/me/holdings/count` | `inventory.item.list` | the caller's stacked owned holdings and their count |
| `PUT /me/equipped` | `inventory.equip.manage` | equip or clear a slot |
| `PUT /me/equipped` | `inventory.equip.manage` | equip or clear a slot |
| `POST /grants` | `inventory.item.grant` | an issuer grants an instance (idempotent per key; 409 `inventory.supply_exhausted`) |
| `POST /grants` | `inventory.item.grant` | an issuer grants an instance (idempotent per key; 409 `inventory.supply_exhausted`) |
| `POST /instances/:id/consume`, `/revoke` | `inventory.item.consume` | an issuer uses up or takes back an instance |
| `POST /instances/:id/consume`, `/revoke` | `inventory.item.consume` | an issuer uses up or takes back an instance |
| `POST /definitions`, `PATCH /definitions/:id` | `inventory.definition.manage` | an issuer's items |
| `POST /definitions`, `PATCH /definitions/:id` | `inventory.definition.manage` | an issuer's items |
+Holdings default to 60 stacks per page (maximum 200). Filters are `issuer` or `site` (a service name), `kind`,
+`rarity`, `equipped=true|false`, `tradable=true|false`, and `text` (a substring of the name). Sort with
+`sort=newest|name|rarity|count`. Pass `next_cursor` as `cursor` with the same filters and sort. The cursor pins the
+newest instance seen on the first page, so later grants do not move stacks during paging. `tradable=true` currently
+returns an empty set because Inventory has no trade or transfer operation. The count endpoint counts stacks after
+the same filters. The original `/items` routes still return individual instances, including non-owned states on `/me`.
+
+For example, `GET /api/v1/me/holdings?kind=live.hat&limit=1` answers:
+
+```json
+{"subject":"usr_…","holdings":[{"definition":{"id":"itd_…","kind":"live.hat","name":"Crown","rarity":"epic"},"instance":{"id":"inv_…","definition_id":"itd_…","state":"owned"},"count":3,"equipped":true,"tradable":false,"kind_slots":["hat"]}],"next_cursor":"…"}
+```
+
Tokens are for audience `openvibe.inventory` (Network's rule: `openvibe.<service>`). A person acts for themself (their
Tokens are for audience `openvibe.inventory` (Network's rule: `openvibe.<service>`). A person acts for themself (their
token or this site's session; a cookie write must come from this site). A service
token or this site's session; a cookie write must come from this site). A service
acting for a person sends `X-OV-Subject: usr_…` and holds the route's capability. A public read needs no token; a
acting for a person sends `X-OV-Subject: usr_…` and holds the route's capability. A public read needs no token; a
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- The common per-person owned scan and stable newest boundary; the existing owner index
+-- still serves reads that include consumed and revoked instances.
+CREATE INDEX inventory_instances_owned_page ON inventory_instances (owner_subject, acquired_at DESC, id DESC)
+ INCLUDE (definition_id) WHERE state = 'owned';
+CREATE INDEX inventory_instances_owned_stack ON inventory_instances (owner_subject, definition_id, acquired_at DESC, id DESC)
+ WHERE state = 'owned';
+CREATE INDEX inventory_definitions_name_sort ON inventory_definitions ((lower(name)) COLLATE "C", id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+(() => {
+ 'use strict';
+ const marker = document.getElementById('inventory-next');
+ const root = document.getElementById('inventory-holdings');
+ if (!marker || !root || !('IntersectionObserver' in window)) return;
+ const own = marker.dataset.own === 'true';
+ const kindNames = JSON.parse(root.dataset.kindNames || '{}');
+ const itemPath = (id) => `${location.pathname.startsWith('/inventory/') ? '/inventory' : ''}/items/${encodeURIComponent(id)}`;
+ const pagePath = (path) => `${location.pathname.startsWith('/inventory/') ? '/inventory' : ''}${path}`;
+ const el = (tag, className, value) => {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (value != null) node.textContent = value;
+ return node;
+ };
+ function artwork(d) {
+ const box = el('span', `inv-art inv-art-md r-${d.rarity}`);
+ if (d.art && d.art.media_id) {
+ const img = el('img');
+ img.src = `https://openvibe.media/o/${encodeURIComponent(d.art.media_id)}?w=128&fmt=webp`;
+ img.alt = ''; img.loading = 'lazy'; img.width = 64; img.height = 64;
+ box.append(img);
+ } else box.textContent = d.art && d.art.emoji || '◆';
+ return box;
+ }
+ function tile(h) {
+ const d = h.definition;
+ const li = el('li', own ? `inv-tile r-${d.rarity}${h.equipped ? ' is-worn' : ''}` : `inv-card r-${d.rarity}${h.equipped ? ' is-worn' : ''}`);
+ const label = document.createDocumentFragment();
+ label.append(artwork(d), el('span', own ? 'inv-tile-name' : 'inv-card-name', d.name));
+ label.append(el('span', own ? 'inv-tile-sub' : 'inv-rarity', `${d.rarity}${h.count > 1 ? ` · ×${h.count}` : ''}`));
+ if (own && h.kind_slots.length) {
+ const form = el('form', 'inv-tile-form');
+ form.method = 'post'; form.action = pagePath('/me/equip');
+ for (const [name, value] of Object.entries({ kind: d.kind, slot: h.kind_slots[0], instance_id: h.equipped ? '' : h.instance.id })) {
+ const input = el('input'); input.type = 'hidden'; input.name = name; input.value = value; form.append(input);
+ }
+ const button = el('button', 'inv-tile-main'); button.type = 'submit';
+ button.setAttribute('aria-label', `${h.equipped ? 'Take off' : 'Wear'} ${d.name}`);
+ button.append(label, el('span', 'inv-tile-act', h.equipped ? 'Wearing' : 'Wear'));
+ form.append(button); li.append(form);
+ const info = el('a', 'inv-tile-info', 'i'); info.href = itemPath(d.id); info.setAttribute('aria-label', `About ${d.name}`); li.append(info);
+ } else {
+ const a = el('a', own ? 'inv-tile-main' : 'inv-card-link'); a.href = itemPath(d.id); a.append(label); li.append(a);
+ }
+ return li;
+ }
+ function append(h) {
+ const key = h.definition.kind.replace(/\W/g, '-');
+ let section = own ? document.getElementById(`k-${key}`) : [...root.children].find((s) => s.dataset.kind === h.definition.kind);
+ if (!section) {
+ section = el('section', 'inv-kind'); section.dataset.kind = h.definition.kind;
+ if (own) section.id = `k-${key}`;
+ section.append(el('h2', '', kindNames[h.definition.kind] || h.definition.kind)); root.append(section);
+ }
+ let list = section.querySelector(own ? 'ul.inv-tiles' : 'ul.inv-grid');
+ if (!list) { list = el('ul', own ? 'inv-tiles' : 'inv-grid'); section.append(list); }
+ list.append(tile(h));
+ }
+ let busy = false;
+ const observer = new IntersectionObserver(async (entries) => {
+ if (!entries.some((e) => e.isIntersecting) || busy || !marker.dataset.cursor) return;
+ busy = true;
+ try {
+ const target = new URL(marker.dataset.endpoint, location.href);
+ target.searchParams.set('cursor', marker.dataset.cursor);
+ const response = await fetch(target, { credentials: 'same-origin' });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const result = await response.json();
+ for (const holding of result.holdings) append(holding);
+ marker.dataset.cursor = result.next_cursor || '';
+ if (!result.next_cursor) { observer.disconnect(); marker.remove(); }
+ } catch {
+ marker.textContent = 'More items could not load. Scroll to try again.';
+ } finally { busy = false; }
+ }, { rootMargin: '600px' });
+ observer.observe(marker);
+})();
@@ -246,7 +246,7 @@ async function createApp(opts = {}) {
@@ -246,7 +246,7 @@ async function createApp(opts = {}) {
// credentials (openvibe-shared/items.js draws what people wear with them, on every OpenVibe site). Everything else
// credentials (openvibe-shared/items.js draws what people wear with them, on every OpenVibe site). Everything else
// keeps the same-site resource policy and no CORS. A preflight is answered here.
// keeps the same-site resource policy and no CORS. A preflight is answered here.
app.use('/api/v1', require('./http/wallet-cors'));
app.use('/api/v1', require('./http/wallet-cors'));
- const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped))$/;
+ const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped|holdings(?:\/count)?))$/;
app.use('/api/v1', (req, res, next) => {
app.use('/api/v1', (req, res, next) => {
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
@@ -71,6 +71,11 @@ function createApi(ctx) {
@@ -71,6 +71,11 @@ function createApi(ctx) {
r.get('/people/:subject/items', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json(await inv.inventory(req.params.subject, {
r.get('/people/:subject/items', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json(await inv.inventory(req.params.subject, {
cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null,
cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null,
}))));
}))));
+ const holdingQuery = (req) => ({ ...req.query, cursor: req.query.cursor || null });
+ r.get('/people/:subject/holdings/count', read, limits.reads('inventory.item.read'), wrap(async (req, res) =>
+ res.json(await inv.holdings.count(req.params.subject, { ...req.query, own: false }))));
+ r.get('/people/:subject/holdings', read, limits.reads('inventory.item.read'), wrap(async (req, res) =>
+ res.json(await inv.holdings.page(req.params.subject, { ...holdingQuery(req), own: false }))));
r.get('/people/:subject/equipped', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {
r.get('/people/:subject/equipped', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {
if (!SUBJECT_RE.test(req.params.subject)) return http.sendProblem(res, 404, 'inventory.unknown_subject', { detail: 'a usr_ subject', ctx: req.ov });
if (!SUBJECT_RE.test(req.params.subject)) return http.sendProblem(res, 404, 'inventory.unknown_subject', { detail: 'a usr_ subject', ctx: req.ov });
return res.json((await inv.equipped([req.params.subject]))[0]);
return res.json((await inv.equipped([req.params.subject]))[0]);
@@ -87,6 +92,14 @@ function createApi(ctx) {
@@ -87,6 +92,14 @@ function createApi(ctx) {
if (!subject) return undefined;
if (!subject) return undefined;
return res.json(await inv.inventory(subject, { own: true, cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null }));
return res.json(await inv.inventory(subject, { own: true, cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null }));
}));
}));
+ r.get('/me/holdings/count', principal.requireCapability('inventory.item.list'), limits.reads('inventory.item.list'), wrap(async (req, res) => {
+ const subject = personOf(req, res);
+ if (subject) res.json(await inv.holdings.count(subject, { ...req.query, own: true }));
+ }));
+ r.get('/me/holdings', principal.requireCapability('inventory.item.list'), limits.reads('inventory.item.list'), wrap(async (req, res) => {
+ const subject = personOf(req, res);
+ if (subject) res.json(await inv.holdings.page(subject, { ...holdingQuery(req), own: true }));
+ }));
r.put('/me/equipped', principal.requireCapability('inventory.equip.manage'), limits.budget('inventory.equip'), wrap(async (req, res) => {
r.put('/me/equipped', principal.requireCapability('inventory.equip.manage'), limits.budget('inventory.equip'), wrap(async (req, res) => {
const subject = personOf(req, res);
const subject = personOf(req, res);
if (!subject) return undefined;
if (!subject) return undefined;
@@ -45,7 +45,7 @@ function earnHow(d) {
@@ -45,7 +45,7 @@ function earnHow(d) {
/** The item's picture: its emoji, or a Media image, in a square tile tinted by rarity. */
/** The item's picture: its emoji, or a Media image, in a square tile tinted by rarity. */
function art(d, { size = 'md' } = {}) {
function art(d, { size = 'md' } = {}) {
const a = d.art || {};
const a = d.art || {};
- if (a.media_id) return html`<span class="inv-art inv-art-${size} r-${d.rarity}"><img src="https://openvibe.media/o/${a.media_id}" alt="" loading="lazy" width="64" height="64"></span>`;
+ if (a.media_id) return html`<span class="inv-art inv-art-${size} r-${d.rarity}"><img src="https://openvibe.media/o/${a.media_id}?w=${size === 'xl' ? 512 : size === 'sm' ? 64 : 128}&fmt=webp" alt="" loading="lazy" width="64" height="64"></span>`;
return html`<span class="inv-art inv-art-${size} r-${d.rarity}" aria-hidden="true">${a.emoji || '◆'}</span>`;
return html`<span class="inv-art inv-art-${size} r-${d.rarity}" aria-hidden="true">${a.emoji || '◆'}</span>`;
}
}
const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;
const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;
@@ -225,25 +225,26 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
@@ -225,25 +225,26 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
});
});
// ── Your inventory ───────────────────────────────────────
// ── Your inventory ───────────────────────────────────────
- async function ownedView(subject, { own }) {
- const [list, eq, kinds] = await Promise.all([inv.inventory(subject, { own, limit: 200 }), inv.equipped([subject]), inv.listKinds()]);
- const worn = new Set(Object.values(eq[0] ? eq[0].slots : {}).map((x) => x.instance_id));
- const owned = list.instances.filter((i) => i.state === 'owned');
+ async function ownedView(subject, { own, cursor = null }) {
+ const [list, eq, kinds, total] = await Promise.all([inv.holdings.page(subject, { own, cursor }), inv.equipped([subject]), inv.listKinds(), inv.holdings.count(subject, { own })]);
+ const worn = new Set([...Object.values(eq[0] ? eq[0].slots : {}).map((x) => x.instance_id),
+ ...list.holdings.filter((h) => h.equipped).map((h) => h.instance.id)]);
+ const owned = list.holdings.map((h) => h.instance);
const byKind = new Map(kinds.map((k) => [k.id, []]));
const byKind = new Map(kinds.map((k) => [k.id, []]));
- for (const i of owned) { const d = list.definitions[i.definition_id]; if (d && byKind.has(d.kind)) byKind.get(d.kind).push({ i, d }); }
- return { kinds, byKind, worn, owned, more: !!list.next_cursor };
+ for (const h of list.holdings) { const { instance: i, definition: d } = h; if (byKind.has(d.kind)) byKind.get(d.kind).push({ i, d, count: h.count }); }
+ return { kinds, byKind, worn, owned, more: !!list.next_cursor, cursor: list.next_cursor, total: total.count };
}
}
async function ownPage(req, res) {
async function ownPage(req, res) {
const me = signedIn(req);
const me = signedIn(req);
if (!me) return res.redirect(303, '/auth/login?next=%2Fme');
if (!me) return res.redirect(303, '/auth/login?next=%2Fme');
- const { kinds, byKind, worn, owned, more } = await ownedView(me, { own: true });
+ const { kinds, byKind, worn, owned, more, cursor, total } = await ownedView(me, { own: true, cursor: req.query.cursor || null });
const done = req.query.done ? String(req.query.done) : null;
const done = req.query.done ? String(req.query.done) : null;
const error = req.query.error ? String(req.query.error).slice(0, 200) : null;
const error = req.query.error ? String(req.query.error).slice(0, 200) : null;
page(req, res, {
page(req, res, {
title: 'Your inventory',
title: 'Your inventory',
body: html`<h1>Your inventory</h1>
body: html`<h1>Your inventory</h1>
-<p class="lede">${owned.length ? `${plural(owned.length, 'item', 'items')}. What you wear shows in chat, on stream overlays and on your profile, on every OpenVibe site.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>
+<p class="lede">${total ? `${plural(total, 'item', 'items')}. What you wear shows in chat, on stream overlays and on your profile, on every OpenVibe site.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>
${done ? html`<p class="notice ok" role="status">${done === 'equipped' ? 'Equipped. It shows everywhere that kind is shown.' : 'Taken off.'}</p>` : ''}
${done ? html`<p class="notice ok" role="status">${done === 'equipped' ? 'Equipped. It shows everywhere that kind is shown.' : 'Taken off.'}</p>` : ''}
${error ? html`<p class="notice bad" role="alert">${error}</p>` : ''}
${error ? html`<p class="notice bad" role="alert">${error}</p>` : ''}
${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-h"><h2 id="inv-wearing-h" class="sr-only">What you wear</h2>
${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-h"><h2 id="inv-wearing-h" class="sr-only">What you wear</h2>
@@ -252,17 +253,17 @@ ${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-
@@ -252,17 +253,17 @@ ${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-
return html`<li><a class="inv-slot${on ? '' : ' is-empty'}" href="#k-${k.id.replace(/\W/g, '-')}">${on ? art(on.d, { size: 'sm' }) : html`<span class="inv-art inv-art-sm inv-art-none" aria-hidden="true"></span>`}<span class="inv-slot-t"><small>${k.name}</small><b>${on ? on.d.name : 'Nothing worn'}</b></span></a></li>`;
return html`<li><a class="inv-slot${on ? '' : ' is-empty'}" href="#k-${k.id.replace(/\W/g, '-')}">${on ? art(on.d, { size: 'sm' }) : html`<span class="inv-art inv-art-sm inv-art-none" aria-hidden="true"></span>`}<span class="inv-slot-t"><small>${k.name}</small><b>${on ? on.d.name : 'Nothing worn'}</b></span></a></li>`;
})}</ul></section>` : ''}
})}</ul></section>` : ''}
<p class="inv-links"><a href="${url(`/u/${me}`)}">Your public page</a> · <a href="${url(`/items`)}">Every item</a></p>
<p class="inv-links"><a href="${url(`/u/${me}`)}">Your public page</a> · <a href="${url(`/items`)}">Every item</a></p>
-${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}">
- <h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}${k.slots.length ? ' · tap one to wear it' : ''}</span></h2>
- ${tiles(byKind.get(k.id), ({ i, d }) => tile(d, {
+<div id="inventory-holdings" data-kind-names="${JSON.stringify(Object.fromEntries(kinds.map((k) => [k.id, k.name])))}">${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}">
+ <h2>${k.name} <span class="muted small">${k.slots.length ? 'tap one to wear it' : ''}</span></h2>
+ ${tiles(byKind.get(k.id), ({ i, d, count }) => tile(d, {
worn: worn.has(i.id),
worn: worn.has(i.id),
- note: i.serial ? `#${i.serial}` : '',
+ note: count > 1 ? `×${count}` : i.serial ? `#${i.serial}` : '',
form: k.slots.length ? (label) => html`<form method="post" action="${url(`/me/equip`)}" class="inv-tile-form">
form: k.slots.length ? (label) => html`<form method="post" action="${url(`/me/equip`)}" class="inv-tile-form">
<input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}"><input type="hidden" name="instance_id" value="${worn.has(i.id) ? '' : i.id}">
<input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}"><input type="hidden" name="instance_id" value="${worn.has(i.id) ? '' : i.id}">
<button type="submit" class="inv-tile-main" aria-label="${worn.has(i.id) ? `Take off ${d.name}` : `Wear ${d.name}`}">${label}<span class="inv-tile-act">${worn.has(i.id) ? 'Wearing' : 'Wear'}</span></button></form>` : null,
<button type="submit" class="inv-tile-main" aria-label="${worn.has(i.id) ? `Take off ${d.name}` : `Wear ${d.name}`}">${label}<span class="inv-tile-act">${worn.has(i.id) ? 'Wearing' : 'Wear'}</span></button></form>` : null,
}), { first: 24, more: `More ${k.name.toLowerCase()} items` })}
}), { first: 24, more: `More ${k.name.toLowerCase()} items` })}
-</section>`)}
-${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="${url(`/api/v1/me/items`)}">/api/v1/me/items</a>.</p>` : ''}`,
+</section>`)}</div>
+${more ? html`<div id="inventory-next" data-endpoint="${url('/api/v1/me/holdings')}" data-cursor="${cursor}" data-own="true"><a href="${url(`/me?cursor=${encodeURIComponent(cursor)}`)}">Next items</a></div><script src="${url('/holdings.js')}" defer></script>` : ''}`,
});
});
}
}
r.get('/me', ownPage);
r.get('/me', ownPage);
@@ -285,17 +286,18 @@ ${more ? html`<p class="muted">Showing the newest 200. The full list is at <a hr
@@ -285,17 +286,18 @@ ${more ? html`<p class="muted">Showing the newest 200. The full list is at <a hr
r.get('/u/:subject', async (req, res) => {
r.get('/u/:subject', async (req, res) => {
const subject = String(req.params.subject);
const subject = String(req.params.subject);
if (!SUBJECT_RE.test(subject)) return notFound(req, res, 'There is no such person.');
if (!SUBJECT_RE.test(subject)) return notFound(req, res, 'There is no such person.');
- const { kinds, byKind, worn, owned } = await ownedView(subject, { own: false });
+ const { kinds, byKind, worn, owned, more, cursor, total } = await ownedView(subject, { own: false, cursor: req.query.cursor || null });
const mine = signedIn(req) === subject;
const mine = signedIn(req) === subject;
const wornItems = owned.filter((i) => worn.has(i.id));
const wornItems = owned.filter((i) => worn.has(i.id));
page(req, res, {
page(req, res, {
index: false, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: false, cache: signedIn(req) ? null : PUBLIC_CACHE,
title: mine ? 'Your public inventory' : 'An OpenVibe inventory',
title: mine ? 'Your public inventory' : 'An OpenVibe inventory',
body: html`<h1>${mine ? 'Your public inventory' : 'An OpenVibe inventory'}</h1>
body: html`<h1>${mine ? 'Your public inventory' : 'An OpenVibe inventory'}</h1>
-<p class="lede">${plural(owned.length, 'item', 'items')}${wornItems.length ? `, ${wornItems.length} worn` : ''}.${mine ? ' This is what anyone sees.' : ''}</p>
-${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind"><h2>${k.name}</h2>
- <ul class="inv-grid">${byKind.get(k.id).map(({ i, d }) => card(d, { worn: worn.has(i.id), extra: worn.has(i.id) ? html`<p class="inv-worn">Wearing</p>` : '' }))}</ul></section>`)}
-${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,
+<p class="lede">${plural(total, 'item', 'items')}${wornItems.length ? `, ${wornItems.length} worn` : ''}.${mine ? ' This is what anyone sees.' : ''}</p>
+<div id="inventory-holdings" data-kind-names="${JSON.stringify(Object.fromEntries(kinds.map((k) => [k.id, k.name])))}">${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" data-kind="${k.id}"><h2>${k.name}</h2>
+ <ul class="inv-grid">${byKind.get(k.id).map(({ i, d, count }) => card(d, { worn: worn.has(i.id), owned: count > 1 ? `×${count}` : null, extra: worn.has(i.id) ? html`<p class="inv-worn">Wearing</p>` : '' }))}</ul></section>`)}</div>
+${!total ? html`<p class="muted">Nothing to show yet.</p>` : ''}
+${more ? html`<div id="inventory-next" data-endpoint="${url(`/api/v1/people/${subject}/holdings`)}" data-cursor="${cursor}"><a href="${url(`/u/${subject}?cursor=${encodeURIComponent(cursor)}`)}">Next items</a></div><script src="${url('/holdings.js')}" defer></script>` : ''}`,
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+
+// Stacked, snapshot-bounded reads. The boundary is the newest instance present on page one;
+// grants made during scrolling cannot move an existing stack or create a new one in later pages.
+const { InventoryError, SUBJECT_RE, RARITY } = require('./store');
+const SORTS = new Set(['newest', 'name', 'rarity', 'count']);
+const bad = (detail) => { throw new InventoryError(400, 'inventory.bad_holdings_query', detail); };
+const cursorError = () => { throw new InventoryError(400, 'inventory.bad_cursor', 'cursor does not match these filters and sort'); };
+
+function optionsOf(input = {}) {
+ const sort = String(input.sort || 'newest');
+ if (!SORTS.has(sort)) bad('sort must be newest, name, rarity or count');
+ const limit = input.limit == null ? 60 : Number(input.limit);
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) bad('limit must be from 1 to 200');
+ const equipped = input.equipped == null ? null : String(input.equipped);
+ const tradable = input.tradable == null ? null : String(input.tradable);
+ if (equipped != null && !['true', 'false'].includes(equipped)) bad('equipped must be true or false');
+ if (tradable != null && !['true', 'false'].includes(tradable)) bad('tradable must be true or false');
+ const issuer = input.issuer ? String(input.issuer) : input.site ? `service:${String(input.site)}` : null;
+ const filter = { issuer, kind: input.kind ? String(input.kind) : null, rarity: input.rarity ? String(input.rarity) : null,
+ equipped, tradable, text: input.text ? String(input.text).trim().slice(0, 100).toLowerCase() : null, sort };
+ if (filter.rarity && !RARITY.includes(filter.rarity)) bad('unknown rarity');
+ return { filter, limit };
+}
+
+function createHoldings(db) {
+ function conditions(subject, own, f, upper) {
+ const p = [subject];
+ const bind = (v) => { p.push(v); return `$${p.length}`; };
+ const where = [`i.owner_subject = $1`, `i.state = 'owned'`];
+ if (!own) where.push("d.status IN ('published', 'retired')");
+ if (upper) where.push(`(i.acquired_at, i.id) <= (${bind(upper.at)}, ${bind(upper.id)})`);
+ if (f.issuer) where.push(`d.issuer = ${bind(f.issuer)}`);
+ if (f.kind) where.push(`d.kind = ${bind(f.kind)}`);
+ if (f.rarity) where.push(`d.rarity = ${bind(f.rarity)}`);
+ if (f.text) where.push(`position(${bind(f.text)} in lower(d.name)) > 0`);
+ if (f.equipped) where.push(`${f.equipped === 'true' ? '' : 'NOT '}EXISTS (SELECT 1 FROM inventory_equipped e
+ JOIN inventory_instances ei ON ei.id = e.instance_id WHERE e.owner_subject = i.owner_subject AND ei.definition_id = i.definition_id)`);
+ // Inventory has no trade or transfer operation; every holding is currently non-tradable.
+ if (f.tradable === 'true') where.push('false');
+ return { p, bind, where: where.join(' AND ') };
+ }
+
+ async function count(subject, { own = false, ...input } = {}) {
+ if (!SUBJECT_RE.test(String(subject))) throw new InventoryError(404, 'inventory.unknown_subject', 'a usr_ subject');
+ const { filter } = optionsOf(input);
+ const q = conditions(subject, own, filter, null);
+ const total = await db.value(`SELECT count(DISTINCT i.definition_id) FROM inventory_instances i
+ JOIN inventory_definitions d ON d.id = i.definition_id WHERE ${q.where}`, q.p);
+ return { subject, count: Number(total) };
+ }
+
+ async function page(subject, { own = false, cursor = null, ...input } = {}) {
+ if (!SUBJECT_RE.test(String(subject))) throw new InventoryError(404, 'inventory.unknown_subject', 'a usr_ subject');
+ const { filter, limit } = optionsOf(input);
+ const signature = JSON.stringify([subject, own, filter]);
+ let state;
+ if (cursor) {
+ try { state = JSON.parse(Buffer.from(String(cursor), 'base64url').toString()); } catch { cursorError(); }
+ if (!state || state.signature !== signature || typeof state.key !== 'string' || typeof state.id !== 'string' ||
+ !state.upper || typeof state.upper.at !== 'string' || typeof state.upper.id !== 'string') cursorError();
+ }
+ const upper = state ? state.upper : await db.maybe(`SELECT acquired_at AS at, id FROM inventory_instances
+ WHERE owner_subject = $1 AND state = 'owned' ORDER BY acquired_at DESC, id DESC LIMIT 1`, [subject]);
+ if (!upper) return { subject, holdings: [], next_cursor: null };
+ const q = conditions(subject, own, filter, upper);
+ const key = { newest: "max(i.acquired_at || '|' || i.id)", name: 'lower(d.name)', rarity: "CASE d.rarity WHEN 'common' THEN '0' WHEN 'uncommon' THEN '1' WHEN 'rare' THEN '2' WHEN 'epic' THEN '3' ELSE '4' END", count: "lpad(count(*)::text, 12, '0')" }[filter.sort];
+ const direction = filter.sort === 'name' ? 'ASC' : 'DESC';
+ const rows = filter.sort === 'newest' ? await db.many(`WITH heads AS MATERIALIZED (
+ SELECT i.*, d.kind, d.name, d.description, d.art, d.rarity, d.issuer, d.status,
+ k.slots AS kind_slots, i.id AS representative_id, (i.acquired_at || '|' || i.id) COLLATE "C" AS sort_key,
+ EXISTS (SELECT 1 FROM inventory_equipped e JOIN inventory_instances ei ON ei.id = e.instance_id
+ WHERE e.owner_subject = i.owner_subject AND ei.definition_id = i.definition_id) AS equipped
+ FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id
+ JOIN inventory_kinds k ON k.id = d.kind
+ WHERE ${q.where}
+ AND NOT EXISTS (SELECT 1 FROM inventory_instances newer WHERE newer.owner_subject = i.owner_subject
+ AND newer.definition_id = i.definition_id AND newer.state = 'owned'
+ AND (newer.acquired_at, newer.id) > (i.acquired_at, i.id)
+ AND (newer.acquired_at, newer.id) <= (${q.bind(upper.at)}, ${q.bind(upper.id)}))
+ ${state ? `AND (i.acquired_at || '|' || i.id, i.definition_id) < (${q.bind(state.key)}, ${q.bind(state.id)})` : ''}
+ ORDER BY i.acquired_at DESC, i.id DESC LIMIT ${q.bind(limit + 1)}
+ ) SELECT h.*, (SELECT count(*)::int FROM inventory_instances si WHERE si.owner_subject = h.owner_subject
+ AND si.definition_id = h.definition_id AND si.state = 'owned'
+ AND (si.acquired_at, si.id) <= (${q.bind(upper.at)}, ${q.bind(upper.id)})) AS quantity
+ FROM heads h ORDER BY h.acquired_at DESC, h.id DESC`, q.p) : await db.many(`WITH groups AS (
+ SELECT i.definition_id, count(*)::int AS quantity, ${key} COLLATE "C" AS sort_key,
+ (array_agg(i.id ORDER BY i.acquired_at DESC, i.id DESC))[1] AS representative_id
+ FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id
+ WHERE ${q.where} GROUP BY i.definition_id, d.name, d.rarity
+ ) SELECT g.*, i.*, d.*, k.slots AS kind_slots,
+ EXISTS (SELECT 1 FROM inventory_equipped e JOIN inventory_instances ei ON ei.id = e.instance_id
+ WHERE e.owner_subject = i.owner_subject AND ei.definition_id = i.definition_id) AS equipped
+ FROM groups g JOIN inventory_instances i ON i.id = g.representative_id
+ JOIN inventory_definitions d ON d.id = g.definition_id
+ JOIN inventory_kinds k ON k.id = d.kind
+ ${state ? `WHERE (g.sort_key, g.definition_id) ${direction === 'ASC' ? '>' : '<'} (${q.bind(state.key)}, ${q.bind(state.id)})` : ''}
+ ORDER BY g.sort_key ${direction}, g.definition_id ${direction} LIMIT ${q.bind(limit + 1)}`, q.p);
+ const shown = rows.slice(0, limit);
+ return { subject, holdings: shown.map((r) => ({
+ definition: { id: r.definition_id, kind: r.kind, name: r.name, description: r.description || undefined,
+ art: r.art, rarity: r.rarity, issuer: r.issuer, status: r.status },
+ instance: { id: r.representative_id, definition_id: r.definition_id, owner: subject,
+ state: 'owned', acquired_at: r.acquired_at, ...(r.serial == null ? {} : { serial: Number(r.serial) }) },
+ count: r.quantity, equipped: r.equipped, tradable: false, kind_slots: r.kind_slots,
+ })), next_cursor: rows.length > limit ? Buffer.from(JSON.stringify({ signature, upper, key: shown.at(-1).sort_key, id: shown.at(-1).definition_id })).toString('base64url') : null };
+ }
+ return { page, count };
+}
+
+module.exports = { createHoldings };
@@ -71,6 +71,7 @@ function decodeCursor(c) {
@@ -71,6 +71,7 @@ function decodeCursor(c) {
function createInventory({ s, outbox, log = console }) {
function createInventory({ s, outbox, log = console }) {
const db = s.db;
const db = s.db;
+ const holdings = require('./holdings').createHoldings(db);
const ajv = new Ajv({ strict: false, allErrors: false });
const ajv = new Ajv({ strict: false, allErrors: false });
const validators = new Map(); // kind id + updated_at → compiled attribute schema
const validators = new Map(); // kind id + updated_at → compiled attribute schema
@@ -470,7 +471,7 @@ function createInventory({ s, outbox, log = console }) {
@@ -470,7 +471,7 @@ function createInventory({ s, outbox, log = console }) {
return {
return {
listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,
listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,
- grant, consume, inventory, equipped, equip, ledger, review, workshopItems, takedown, checkWorkshopQuota,
+ grant, consume, inventory, holdings, equipped, equip, ledger, review, workshopItems, takedown, checkWorkshopQuota,
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+const assert = require('assert');
+const { performance } = require('perf_hooks');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const person = t.network.addUser('holdings-reader');
+ const db = t.ctx.s.db;
+ const api = (path) => t.get(`/api/v1${path}`, { as: person });
+ try {
+ // One statement per table keeps fixture setup separate from the measured read.
+ await db.exec(`INSERT INTO inventory_definitions
+ (id, kind, name, art, rarity, attributes, issuer, supply_issued, status, created_at, updated_at)
+ SELECT 'itd_' || lpad(n::text, 26, '0'), 'live.hat', 'Hat ' || lpad(n::text, 5, '0'), '{"emoji":"◆"}'::jsonb,
+ CASE WHEN n % 2 = 0 THEN 'rare' ELSE 'common' END, '{}'::jsonb,
+ CASE WHEN n % 3 = 0 THEN 'service:quest' ELSE 'service:live' END, 1, 'published',
+ '2026-11-10T00:00:00Z', '2026-11-10T00:00:00Z' FROM generate_series(1, 5000) n`);
+ await db.exec(`INSERT INTO inventory_instances
+ (id, definition_id, owner_subject, origin, state, attributes, acquired_at, updated_at)
+ SELECT 'inv_' || lpad(n::text, 26, '0'), 'itd_' || lpad(n::text, 26, '0'), $1,
+ 'earned', 'owned', '{}'::jsonb, '2026-11-10T00:00:00Z', '2026-11-10T00:00:00Z'
+ FROM generate_series(1, 5000) n`, [person.subject]);
+ await db.exec(`INSERT INTO inventory_instances
+ (id, definition_id, owner_subject, origin, state, attributes, acquired_at, updated_at)
+ VALUES ('inv_' || lpad('5001', 26, '0'), 'itd_' || lpad('5000', 26, '0'), $1,
+ 'earned', 'owned', '{}'::jsonb, '2026-11-10T00:00:01Z', '2026-11-10T00:00:01Z')`, [person.subject]);
+ await db.exec('ANALYZE inventory_instances');
+ await db.exec(`INSERT INTO inventory_equipped (owner_subject, kind, slot, instance_id, updated_at)
+ VALUES ($1, 'live.hat', 'hat', 'inv_' || lpad('5000', 26, '0'), '2026-11-10T00:00:02Z')`, [person.subject]);
+ await check('stack count, count route, filters and sorts are SQL-backed', async () => {
+ const first = (await api('/me/holdings?limit=2')).json();
+ assert.strictEqual(first.holdings[0].count, 2);
+ assert.strictEqual(first.holdings[0].equipped, true, 'another instance in the stack is equipped');
+ assert.strictEqual((await api('/me/holdings/count')).json().count, 5000);
+ assert.strictEqual((await api('/me/holdings/count?rarity=rare')).json().count, 2500);
+ assert.strictEqual((await api('/me/holdings/count?issuer=service%3Aquest')).json().count, 1666);
+ assert.strictEqual((await api('/me/holdings/count?site=quest')).json().count, 1666);
+ assert.strictEqual((await api('/me/holdings/count?kind=live.hat')).json().count, 5000);
+ assert.strictEqual((await api('/me/holdings/count?equipped=true')).json().count, 1);
+ assert.strictEqual((await api('/me/holdings/count?equipped=false')).json().count, 4999);
+ assert.strictEqual((await api('/me/holdings/count?text=05000')).json().count, 1);
+ assert.strictEqual((await api('/me/holdings/count?tradable=true')).json().count, 0);
+ assert.strictEqual((await api('/me/holdings?sort=count&limit=1')).json().holdings[0].count, 2);
+ assert.strictEqual((await api('/me/holdings?sort=name&limit=1')).json().holdings[0].definition.name, 'Hat 00001');
+ assert.strictEqual((await api('/me/holdings?sort=rarity&limit=1')).json().holdings[0].definition.rarity, 'rare');
+ assert.strictEqual((await api('/me/holdings?limit=201')).status, 400);
+ });
+ await check('cursor holds a stable boundary while new items arrive', async () => {
+ const a = (await api('/me/holdings?limit=2')).json();
+ const byCount = (await api('/me/holdings?sort=count&limit=1')).json();
+ await db.exec(`INSERT INTO inventory_instances
+ (id, definition_id, owner_subject, origin, state, attributes, acquired_at, updated_at)
+ VALUES ('inv_' || lpad('5002', 26, '0'), 'itd_' || lpad('4999', 26, '0'), $1,
+ 'earned', 'owned', '{}'::jsonb, '2026-11-10T00:00:02Z', '2026-11-10T00:00:02Z')`, [person.subject]);
+ const response = await api(`/me/holdings?limit=2&cursor=${encodeURIComponent(a.next_cursor)}`);
+ assert.strictEqual(response.status, 200, `${response.text} ${t.logs().slice(-1000)}`);
+ const b = response.json();
+ assert.strictEqual(b.holdings.length, 2);
+ assert(!b.holdings.some((h) => a.holdings.some((prior) => prior.definition.id === h.definition.id)));
+ assert.strictEqual((await api(`/me/holdings?limit=2&sort=name&cursor=${encodeURIComponent(a.next_cursor)}`)).status, 400);
+ const nextCount = (await api(`/me/holdings?sort=count&limit=1&cursor=${encodeURIComponent(byCount.next_cursor)}`)).json();
+ assert.strictEqual(nextCount.holdings[0].count, 1, 'new grant does not change the count sort snapshot');
+ });
+ await check('public query cannot request private holdings', async () => {
+ await db.exec("UPDATE inventory_definitions SET status = 'draft' WHERE id = 'itd_' || lpad('4998', 26, '0')");
+ const path = `/people/${person.subject}/holdings`;
+ assert.strictEqual((await api(`${path}/count?own=true`)).json().count, 4999);
+ assert.strictEqual((await api(`${path}?own=true&text=04998`)).json().holdings.length, 0);
+ assert.strictEqual((await api('/me/holdings/count')).json().count, 5000);
+ });
+ await check('pages render a first batch and a next-page link', async () => {
+ const mine = await t.get('/me', { as: person });
+ assert.strictEqual(mine.status, 200, mine.text.slice(-300));
+ assert.match(mine.text, /id="inventory-next"/);
+ assert.match(mine.text, /data-endpoint="\/api\/v1\/me\/holdings"/);
+ assert.match(mine.text, /Next items/);
+ const publicPage = await t.get(`/u/${person.subject}`);
+ assert.strictEqual(publicPage.status, 200);
+ assert.match(publicPage.text, /data-endpoint="\/api\/v1\/people\/.+\/holdings"/);
+ });
+ await check('5,000 holdings return a warmed page in under 50 ms on PGlite', async () => {
+ await t.ctx.inv.holdings.page(person.subject, { own: true });
+ const start = performance.now();
+ const page = await t.ctx.inv.holdings.page(person.subject, { own: true });
+ const ms = performance.now() - start;
+ assert.strictEqual(page.holdings.length, 60);
+ if (process.env.INVENTORY_TEST_STORE !== 'pg') assert(ms < 50, `PGlite page took ${ms.toFixed(1)} ms`);
+ process.stdout.write(`holdings page (${process.env.INVENTORY_TEST_STORE === 'pg' ? 'PostgreSQL' : 'PGlite'}): ${ms.toFixed(1)} ms\n`);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -13,7 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -13,7 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
const origin = { origin: 'https://openvibe.community' };
const origin = { origin: 'https://openvibe.community' };
try {
try {
await check('the public reads answer every origin', async () => {
await check('the public reads answer every origin', async () => {
- for (const p of ['/api/v1/kinds', '/api/v1/kinds/live.hat', '/api/v1/definitions?kind=live.hat', `/api/v1/equipped?subjects=${A}`, `/api/v1/people/${A}/items`, `/api/v1/people/${A}/equipped`]) {
+ for (const p of ['/api/v1/kinds', '/api/v1/kinds/live.hat', '/api/v1/definitions?kind=live.hat', `/api/v1/equipped?subjects=${A}`, `/api/v1/people/${A}/items`, `/api/v1/people/${A}/holdings`, `/api/v1/people/${A}/holdings/count`, `/api/v1/people/${A}/equipped`]) {
const r = await t.get(p, { headers: origin });
const r = await t.get(p, { headers: origin });
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', p);
assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', p);