openvibers/openvibe.inventory Commits

Page stacked inventory holdings with stable cursors (#246)

OP OpenVibers committed 3 days ago · d55296b0b1 · 10 files, +344 −23

* Page stacked inventory holdings with stable cursors

* Scope holdings timing budget to PGlite

Changes

ddiff --git a/README.md b/README.md
iindex f1940ff..3ae3d85 100644
---- a/README.md
++++ b/README.md
@@@ -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 (their
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
ddiff --git a/migrations/0009_holdings_reads.sql b/migrations/0009_holdings_reads.sql
nnew file mode 100644
iindex 0000000..79406aa
---- /dev/null
++++ b/migrations/0009_holdings_reads.sql
@@@ -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);
ddiff --git a/public/holdings.js b/public/holdings.js
nnew file mode 100644
iindex 0000000..442b61a
---- /dev/null
++++ b/public/holdings.js
@@@ -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);
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 5c47178..946c4d3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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', '*');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 9bf5716..2405c0c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0cbb05c..2edbcfc 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 hr
r.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>` : ''}`,
});
});
ddiff --git a/server/inventory/holdings.js b/server/inventory/holdings.js
nnew file mode 100644
iindex 0000000..23ec200
---- /dev/null
++++ b/server/inventory/holdings.js
@@@ -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 };
ddiff --git a/server/inventory/store.js b/server/inventory/store.js
iindex 004274c..fac8315 100644
---- a/server/inventory/store.js
++++ b/server/inventory/store.js
@@@ -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,
};
}
ddiff --git a/test/holdings.test.js b/test/holdings.test.js
nnew file mode 100644
iindex 0000000..18463d8
---- /dev/null
++++ b/test/holdings.test.js
@@@ -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; });
ddiff --git a/test/public-cors.test.js b/test/public-cors.test.js
iindex 446f17d..3b9059f 100644
---- a/test/public-cors.test.js
++++ b/test/public-cors.test.js
@@@ -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);