openvibers/openvibe.inventory Commits

Inventory: item pages in OpenVibe.Search (search-feed, inventory.index_document.*) (#19)

OP OpenVibers committed 10 Oct 2026 · 43810f9211 · 10 files, +279 −12

Plan T9 ("Search: real documents from every product") and T21 (the OpenVibe inventory). Inventory's item pages go into OpenVibe.Search.

- `server/search-index.js` uses `openvibe-publishing/search-feed` (1.4.0): one `search.index-document@1` per item page (`/items/<id>`), sent as `inventory.index_document.upserted|deleted` (Contracts 0.128.0) through Inventory's existing events outbox.
  - The document is what the page shows: name, rarity, kind, description, wearable or collectible, who gives it, and "earn it on OpenVibe.Quest" for quest rewards. Never who owns it.
  - Facets: kind, rarity, issuer, wearable.
  - A published item is a document. A retired one stays but is marked noindex, as its page is. A draft or an item in review is a tombstone, as its page answers 404.
- **When it runs:** `watch(inv)` wraps the five definition writes (create, edit, Workshop edit, review, takedown), so each names the item it changed and the item syncs 250 ms later. A sweep a minute after boot and every ten minutes catches the rest (the seed, account deletions). `inventory_index_revisions` is the sequencer.
- **Migration `0004_search_index.sql`** (expand): `inventory_index_revisions` only; the outbox table exists already.
- **Delivery:** Inventory already holds `events.event.publish` and is an Events source.
- **Pins:** openvibe-contracts v0.128.0; openvibe-publishing v1.4.0 added.

Note: Contracts 0.128.0's description names `/definitions/<id>` as the page. The real page is `/items/<id>`, which is the URL documents carry; the description gets corrected in the next Contracts release.

Tests: `test/search-index.test.js`, 3 checks:
- every published item is sent once, valid against its contract, and its page answers 200, while a second sweep sends nothing;
- retired is a noindex document and draft a tombstone (with a 404 page);
- a wrapped write syncs its item once, an unchanged item sends nothing, and grant is not wrapped.

Full suite 20/20.

Changes

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