openvibers/openvibe.publishing Commits

openvibe-publishing 1.4.0: search-feed (record pages in OpenVibe.Search) (#19)

OP OpenVibers committed 10 Oct 2026 · d7f1ff46a0 · 9 files, +377 −10

Adds `openvibe-publishing/search-feed` (1.4.0, additive). It lets the products whose pages are records (Work's job ads, Rent's listings, Help's articles, Inventory's definitions, Quest's quests) put those pages in OpenVibe.Search through the same publication glue the content products use. Plan T9: "Search: real documents from every product" and T19 compositions (Rent, Work and Help compose Search).

- `createSearchFeed({ owner, db, outbox, baseUrl, types })`: each type gives `page(row)`, `document(row)` and `rows(afterId, limit)`, plus an optional `exists(ids)`.
- `sync(t, type, row)` / `remove(t, type, id)` run on the write's transaction: one `search.index-document@1` (via `<owner>.index_document.*`) per change. An unchanged row sends nothing. `listed: false` is a tombstone, and nothing is sent for an id Search never had.
- `sweep()` sends every row in id order but re-sends only what changed, and tombstones ids whose row is gone. It uses one transaction per row; a failing row is counted and logged and the pass continues. Concurrent calls share one pass.
- Indexability follows the page's own robots (`noindex`) and expiry, not the article word-count gate.

Tests: `test/search-feed.test.js` (7 cases on PGlite, every event checked against the `search.index-document@1` and `events.event-envelope@1` contracts). Full suite green: 19/20 files, with postgres skipped because there are no containers.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7d04b4b..1318918 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,27 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
++## 1.4.0 — 2026-10-10
++
++**Additive: `openvibe-publishing/search-feed`.** No existing export, table layout, reason code or document shape
++changes; a product on v1.3.0 upgrades by pinning v1.4.0.
++
++### Added — `openvibe-publishing/search-feed`
++
++- `createSearchFeed({ owner, db, outbox, baseUrl, types, now?, indexnow?, log? })` puts a product's record pages
++ (listings, job ads, help articles, catalog items, quests) in OpenVibe.Search through the publication glue: one
++ `search.index-document@1` per row on the caller's transaction handle, revisions from the index sequencer
++ (`<owner>_index_revisions`), and the product's outbox. Each type gives `page(row)`, `document(row)` (title, summary,
++ body, facets, dates, authorship, provenance, plus `listed` and `noindex`) and `rows(afterId, limit)`; `exists(ids)`
++ is optional.
++- `sync(t, type, row)` and `remove(t, type, id)` run inside the write's transaction. An unchanged row sends nothing,
++ `listed: false` is a tombstone, and an id Search never had sends nothing.
++- `sweep()` brings Search level with the tables: every row in id order, re-sending only what changed, and a tombstone
++ for each id whose row is gone (with `exists`). One transaction per row; a failing row is counted and logged and the
++ pass goes on. Concurrent calls share one pass.
++- Indexability follows the page's own robots (`noindex`) and expiry (`expiresAt`), never the publication gate's word
++ count: a record is not an article.
++
## 1.3.0 — 2026-10-04
**Additive: `layout.renderDocument` now forwards the AI summary to the shell.** No existing export,
ddiff --git a/README.md b/README.md
iindex 076c4d9..eade6a2 100644
---- a/README.md
++++ b/README.md
@@@ -67,10 +67,11 @@ Every module is its own entry point and can be used alone.
| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
| `openvibe-publishing/ingest` | The shared ingest chassis: the OpenVibe.Sources client, a named change cursor and the `pullChanges` loop (per-item savepoint isolation, `{applied\|hold\|removed}`), the signed event consumer with an exactly-once inbox, the shared normalisers, and the generic PSL/registrable-host and freshness helpers | `<prefix>_ingest_cursor` |
| `openvibe-publishing/publication` | The shared publication glue: gate → document → `sequencer.stamp` → events → `outbox.enqueue` on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping; it emits exactly `search.index-document@1` and the product's events | — |
++| `openvibe-publishing/search-feed` | Record pages (listings, job ads, help articles, catalog items) in OpenVibe.Search: `sync`/`remove` on the write's transaction, and a `sweep()` that re-sends only what changed and tombstones vanished rows | — |
| `openvibe-publishing/layout` | `renderDocument(o)`: the whole page through `openvibe-shared/shell` `page()` (≥ 2.6.0), with robots and canonical from the gate's decision (throws without one), article times, prev/next, feeds, the app icon, stylesheets, the boost marker, the body frame and the footer init | — |
`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`,
--`.ingest`, `.publication`, `.layout`, …), plus `schema({ … })`, the DDL of several stores at once (below).
++`.ingest`, `.publication`, `.searchFeed`, `.layout`, …), plus `schema({ … })`, the DDL of several stores at once (below).
## Install
ddiff --git a/STATUS.json b/STATUS.json
iindex ee10e4e..9c0ecd0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"wave": "W15",
"kind": "packages",
"package": "openvibe-publishing",
-- "version": "1.3.0",
++ "version": "1.4.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"port": null,
@@@ -25,7 +25,8 @@
"ssr",
"ingest",
"publication",
-- "layout"
++ "layout",
++ "search-feed"
],
"consumers": [
"OpenVibe.Wiki (v1.3.0 on PostgreSQL; public at https://openvibe.wiki)",
ddiff --git a/index.js b/index.js
iindex e6dde35..abfd2d0 100644
---- a/index.js
++++ b/index.js
@@@ -3,7 +3,7 @@
* openvibe-publishing — every module is also its own entry point and can be used alone:
* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
* …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/ai, …/ingest, …/publication,
-- * …/layout
++ * …/search-feed, …/layout
* This root export loads each one lazily, on first access.
*
* schema({ revisions: 'wiki_page', citations: 'wiki', … }) is the DDL of every store a product uses,
@@@ -23,6 +23,7 @@ const MODULES = {
ai: './lib/ai',
ingest: './lib/ingest',
publication: './lib/publication',
++ searchFeed: './lib/search-feed',
layout: './lib/layout',
};
ddiff --git a/lib/search-feed.js b/lib/search-feed.js
nnew file mode 100644
iindex 0000000..8c54b1e
---- /dev/null
++++ b/lib/search-feed.js
@@@ -0,0 +1,166 @@
++'use strict';
++/**
++ * openvibe-publishing/search-feed — a product's own pages in OpenVibe.Search, for the products whose pages are
++ * records rather than publications (listings, job ads, help articles, catalog items, quests). It is the
++ * publication glue (sequencer revision → search.index-document@1 → the product's outbox, on the caller's
++ * transaction) plus what such a product needs on top: a document described from one row, and a sweep that brings
++ * Search level with the table.
++ *
++ * const feed = createSearchFeed({
++ * owner: 'rent', db, outbox, baseUrl: config.baseUrl, now: store.now, indexnow,
++ * types: {
++ * listing: {
++ * page: (row) => `/listings/${row.id}`,
++ * document: (row) => ({ listed, noindex, title, summary, body, facets, publishedAt, updatedAt, … }),
++ * rows: (afterId, limit) => …, // rows with id > afterId, in id order, in EVERY state
++ * exists: (ids) => …, // the ids of `ids` that still have a row (optional)
++ * },
++ * },
++ * });
++ * await db.tx(async (t) => { …the write…; await feed.sync(t, 'listing', row); });
++ * await db.tx(async (t) => { …the delete…; await feed.remove(t, 'listing', id); });
++ * await feed.sweep(); // → { listing: { seen, sent, removed, failed } }
++ *
++ * document(row) describes the page:
++ * listed false → a tombstone (draft, hidden, removed, expired): Search drops its copy
++ * noindex true → kept in Search but marked noindex, as the page's own robots say
++ * title (required), summary, body, facets, language, publishedAt, updatedAt, expiresAt,
++ * authorship ({ mode: human | ai | hybrid | imported }), provenance (refs, as hooks.buildIndexDocument takes)
++ * Every document is visibility public: a feed carries what anyone may read. Indexability follows the page's own
++ * robots and expiry, not the publication gate's word counts (a listing is not an article).
++ *
++ * The sweep re-sends nothing that did not change (the sequencer gives an unchanged document its old revision), so
++ * it is safe to run at boot, on a timer and after an ingest; with exists(), ids Search holds whose row is gone get a
++ * tombstone. One row is one transaction: a row that fails is counted and logged, and the sweep goes on.
++ */
++const hooks = require('./index-hooks');
++const seo = require('./seo');
++const { createPublication } = require('./publication');
++const { isHandle, sqlOf } = require('./internal');
++
++const PAGE = 200;
++const OWNER_RE = /^[a-z][a-z0-9_]{1,39}$/;
++const TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;
++const POLICY = Object.freeze({ minWords: 0 });
++
++function createSearchFeed({
++ owner, db, outbox, baseUrl, types, prefix = owner, now = () => Date.now(), indexnow = null, log = console,
++ pageSize = PAGE,
++} = {}) {
++ if (!OWNER_RE.test(String(owner || ''))) throw new TypeError('owner must be the service id, e.g. "rent"');
++ if (!isHandle(db)) throw new TypeError('db must be an openvibe-sdk/db handle');
++ if (!baseUrl) throw new TypeError('baseUrl is the site origin the pages live on, e.g. https://openvibe.rent');
++ if (!types || typeof types !== 'object' || !Object.keys(types).length) throw new TypeError('types maps each document type to { page, document, rows }');
++ for (const [type, t] of Object.entries(types)) {
++ if (!TYPE_RE.test(type)) throw new TypeError(`type "${type}" must match ${TYPE_RE}`);
++ for (const fn of ['page', 'document', 'rows']) if (typeof t[fn] !== 'function') throw new TypeError(`types.${type}.${fn} must be a function`);
++ if (t.exists !== undefined && typeof t.exists !== 'function') throw new TypeError(`types.${type}.exists must be a function`);
++ }
++ const sequencer = hooks.createIndexSequencer(db, { prefix, now });
++ const publication = createPublication({ owner, sequencer, outbox, baseUrl, indexnow, db, now });
++ const sql = sqlOf(db);
++ const seqTable = sql.ident(sequencer.table);
++
++ function typeOf(type) {
++ const t = types[type];
++ if (!t) throw new TypeError(`unknown document type "${type}" (this feed has ${Object.keys(types).join(', ')})`);
++ return t;
++ }
++
++ /** The search.index-document@1 for one row (a tombstone when the row is not listed). */
++ function documentOf(type, row) {
++ const t = typeOf(type);
++ const d = t.document(row) || {};
++ const id = String(d.id != null ? d.id : row.id);
++ if (d.listed === false) return hooks.tombstone({ owner, type, id, revision: 0 });
++ const canonicalUrl = publication.abs(t.page(row));
++ const facts = { state: 'published', visibility: 'public', canonicalUrl, noindex: Boolean(d.noindex) };
++ if (d.expiresAt != null) facts.expiresAt = d.expiresAt;
++ const decision = seo.evaluate(facts, { policy: POLICY, now: now() });
++ if (!decision.listable) return hooks.tombstone({ owner, type, id, revision: 0 });
++ return hooks.buildIndexDocument({
++ owner, type, id, revision: 0, state: 'published', visibility: 'public', canonicalUrl,
++ title: d.title, summary: d.summary || null, body: d.body || '', facets: d.facets || {},
++ language: d.language || null, authorship: d.authorship || null, provenance: d.provenance || [],
++ publishedAt: d.publishedAt || null, updatedAt: d.updatedAt || null, decision,
++ });
++ }
++
++ /** Index one row on the caller's handle (the transaction that wrote it). → the stamped document, or null when nothing was sent. */
++ async function sync(t, type, row, { traceparent } = {}) {
++ if (!isHandle(t)) throw new TypeError('sync(t, type, row): pass the transaction handle (or db)');
++ const doc = documentOf(type, row);
++ return await publication.index(t, { document: doc, page: doc.deleted ? null : typeOf(type).page(row), traceparent });
++ }
++
++ /** A tombstone for a row that is gone (nothing is sent for an id Search never had). */
++ async function remove(t, type, id, { traceparent } = {}) {
++ if (!isHandle(t)) throw new TypeError('remove(t, type, id): pass the transaction handle (or db)');
++ typeOf(type);
++ return await publication.tombstone(t, { type, id: String(id), traceparent });
++ }
++
++ async function sweepType(type) {
++ const t = typeOf(type);
++ const stats = { seen: 0, sent: 0, removed: 0, failed: 0 };
++ let after = '';
++ for (;;) {
++ const rows = await t.rows(after, pageSize);
++ if (!rows || !rows.length) break;
++ for (const row of rows) {
++ stats.seen += 1;
++ try {
++ const out = await db.tx(async (h) => await sync(h, type, row));
++ if (out) stats.sent += 1;
++ } catch (err) {
++ stats.failed += 1;
++ (log.warn || log.log).call(log, `[search-feed] ${owner} ${type} ${row && row.id}: ${err.message}`);
++ }
++ }
++ const last = rows[rows.length - 1];
++ const next = String(last.id);
++ if (next <= after) throw new Error(`types.${type}.rows must return rows in id order after the cursor`);
++ after = next;
++ if (rows.length < pageSize) break;
++ }
++ if (!t.exists) return stats;
++ // Ids Search was given whose row is gone: a tombstone each (an id already tombstoned sends nothing).
++ let cursor = '';
++ for (;;) {
++ const held = (await db.many(sql`SELECT id FROM ${seqTable} WHERE owner = ${owner} AND type = ${type} AND id > ${cursor}
++ ORDER BY id LIMIT ${pageSize}`)).map((r) => r.id);
++ if (!held.length) break;
++ const alive = new Set((await t.exists(held)).map(String));
++ for (const id of held) {
++ if (alive.has(id)) continue;
++ try {
++ const out = await db.tx(async (h) => await remove(h, type, id));
++ if (out) stats.removed += 1;
++ } catch (err) {
++ stats.failed += 1;
++ (log.warn || log.log).call(log, `[search-feed] ${owner} ${type} ${id} (tombstone): ${err.message}`);
++ }
++ }
++ cursor = held[held.length - 1];
++ if (held.length < pageSize) break;
++ }
++ return stats;
++ }
++
++ let running = null;
++ /** Bring Search level with the tables, one type after another. Concurrent calls share one pass. */
++ function sweep() {
++ if (!running) {
++ running = (async () => {
++ const out = {};
++ for (const type of Object.keys(types)) out[type] = await sweepType(type);
++ return out;
++ })().finally(() => { running = null; });
++ }
++ return running;
++ }
++
++ return { owner, types: Object.keys(types), sequencer, publication, documentOf, sync, remove, sweep };
++}
++
++module.exports = { createSearchFeed };
ddiff --git a/package-lock.json b/package-lock.json
iindex db47c74..c82276f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-publishing",
-- "version": "1.3.0",
++ "version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-publishing",
-- "version": "1.3.0",
++ "version": "1.4.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 2cb9332..7ee0f98 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-publishing",
-- "version": "1.3.0",
++ "version": "1.4.0",
"description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.",
"main": "index.js",
"exports": {
@@@ -19,7 +19,8 @@
"./ai": "./lib/ai.js",
"./ingest": "./lib/ingest.js",
"./publication": "./lib/publication.js",
-- "./layout": "./lib/layout.js"
++ "./layout": "./lib/layout.js",
++ "./search-feed": "./lib/search-feed.js"
},
"files": [
"index.js",
ddiff --git a/test/package.test.js b/test/package.test.js
iindex 815bdbe..aa39b74 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './pa
test('every subpath export resolves to an existing module', () => {
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
-- ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
++ ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'search-feed', 'seo', 'ssr', 'taxonomy']);
for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
});
@@@ -37,7 +37,7 @@ test('the root export is lazy and exposes every module', () => {
assert.strictEqual(out.before, 0);
assert.ok(out.after > 0);
assert.strictEqual(out.v, pkg.version);
-- assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', 'seo', 'ssr', 'taxonomy', 'version']);
++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', 'searchFeed', 'seo', 'ssr', 'taxonomy', 'version']);
});
test('the package owns no runtime: no listen(), no database of its own, no env config in lib/', () => {
ddiff --git a/test/search-feed.test.js b/test/search-feed.test.js
nnew file mode 100644
iindex 0000000..a13b1e6
---- /dev/null
++++ b/test/search-feed.test.js
@@@ -0,0 +1,176 @@
++'use strict';
++/**
++ * openvibe-publishing/search-feed (1.4.0): a product's record pages in OpenVibe.Search.
++ *
++ * - sync() stamps and enqueues exactly search.index-document@1 (as Search's webhook reads it) on the caller's
++ * handle, and an unchanged row sends nothing;
++ * - listed:false and remove() are tombstones, and nothing is sent for an id Search never had;
++ * - noindex and expiry keep the document with a noindex decision; there is no word-count gate;
++ * - sweep() pages through rows in id order, re-sends only what changed, tombstones ids whose row is gone,
++ * counts a failing row and goes on;
++ * - a rolled-back write leaves neither the revision nor the event.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { createPgOutbox } = require('openvibe-sdk/events');
++const { openDb, fakeClock, suite, sql } = require('./helpers/db');
++const { createSearchFeed } = require('../lib/search-feed');
++
++const { test, run } = suite();
++
++function searchReads(env) {
++ const m = /^([a-z][a-z0-9-]{1,39})\.index_document\.(upserted|deleted)$/.exec(env.event_type);
++ assert.ok(m, `not an index_document event: ${env.event_type}`);
++ assert.strictEqual(m[1], env.source);
++ const p = env.payload;
++ const doc = m[2] === 'deleted' ? { owner: env.source, type: p.type, id: p.id, revision: p.revision, deleted: true } : { ...p, owner: env.source };
++ assert.strictEqual(env.subject.id, doc.id);
++ assert.strictEqual(env.subject.revision, doc.revision);
++ const v = contracts.validate('search.index-document@1', doc);
++ assert.ok(v.valid, `search.index-document@1: ${JSON.stringify(v.errors)}`);
++ const e = contracts.validate('events.event-envelope@1', env);
++ assert.ok(e.valid, `events.event-envelope@1: ${JSON.stringify(e.errors)}`);
++ return doc;
++}
++
++const silent = { warn() {}, log() {} };
++
++async function setup({ log = silent } = {}) {
++ const db = await openDb();
++ const clock = fakeClock();
++ await db.query(`CREATE TABLE listings (id text COLLATE "C" PRIMARY KEY, title text NOT NULL, body text NOT NULL DEFAULT '',
++ status text NOT NULL DEFAULT 'active', city text, expires_at bigint, flagged boolean NOT NULL DEFAULT false)`);
++ const events = { prepare: (env) => ({ ...env, event_id: contracts.ids.newId('event') }), publish: async () => ({ results: [] }) };
++ const outbox = createPgOutbox(db, { events, table: 'event_outbox', now: clock });
++ await outbox.ensureSchema();
++ const feed = createSearchFeed({
++ owner: 'rent', db, outbox, baseUrl: 'https://openvibe.rent', now: clock, log,
++ types: {
++ listing: {
++ page: (r) => `/listings/${r.id}`,
++ document: (r) => {
++ if (r.title === 'boom') throw new TypeError('bad row');
++ return {
++ listed: r.status === 'active', noindex: r.flagged, expiresAt: r.expires_at == null ? null : Number(r.expires_at),
++ title: r.title, summary: r.body.slice(0, 40), body: r.body, facets: { city: r.city },
++ authorship: { mode: 'human' }, updatedAt: '2026-09-22T12:00:00Z',
++ };
++ },
++ rows: (after, limit) => db.many(sql`SELECT * FROM listings WHERE id > ${after} ORDER BY id LIMIT ${limit}`),
++ exists: async (ids) => (await db.many(sql`SELECT id FROM listings WHERE id = ANY(${ids})`)).map((r) => r.id),
++ },
++ },
++ });
++ await feed.sequencer.ensureSchema();
++ const envelopes = async () => (await db.many(sql`SELECT envelope FROM event_outbox ORDER BY id`)).map((r) => r.envelope);
++ const add = (id, extra = {}) => db.exec(sql`INSERT INTO listings (id, title, body, city, status, expires_at, flagged)
++ VALUES (${id}, ${extra.title || `Flat ${id}`}, ${extra.body || 'Two rooms near the park.'}, ${extra.city || 'Leeds'},
++ ${extra.status || 'active'}, ${extra.expires_at == null ? null : extra.expires_at}, ${Boolean(extra.flagged)})`);
++ const row = (id) => db.one(sql`SELECT * FROM listings WHERE id = ${id}`);
++ return { db, clock, feed, envelopes, add, row };
++}
++
++test('sync: one search.index-document@1 on the caller\'s handle; an unchanged row sends nothing; a change is revision + 1', async () => {
++ const { db, feed, envelopes, add, row } = await setup();
++ await add('lst_a');
++ const first = await db.tx(async (t) => await feed.sync(t, 'listing', await row('lst_a')));
++ assert.strictEqual(first.revision, 1);
++ let envs = await envelopes();
++ assert.strictEqual(envs.length, 1);
++ const doc = searchReads(envs[0]);
++ assert.strictEqual(envs[0].event_type, 'rent.index_document.upserted');
++ assert.strictEqual(doc.canonical_url, 'https://openvibe.rent/listings/lst_a');
++ assert.strictEqual(doc.visibility, 'public');
++ assert.deepStrictEqual(doc.indexability, { decision: 'index', reasons: [] }, 'no word-count gate on records');
++ assert.deepStrictEqual(doc.facets, { city: 'Leeds' });
++ assert.strictEqual(await db.tx(async (t) => await feed.sync(t, 'listing', await row('lst_a'))), null, 'unchanged → nothing');
++ assert.strictEqual((await envelopes()).length, 1);
++ await db.exec(sql`UPDATE listings SET title = 'Flat A, renovated' WHERE id = 'lst_a'`);
++ const second = await db.tx(async (t) => await feed.sync(t, 'listing', await row('lst_a')));
++ assert.strictEqual(second.revision, 2);
++ envs = await envelopes();
++ assert.strictEqual(searchReads(envs[1]).title, 'Flat A, renovated');
++});
++
++test('listed:false and remove() are tombstones; nothing for an id Search never had', async () => {
++ const { db, feed, envelopes, add, row } = await setup();
++ await add('lst_hidden', { status: 'hidden' });
++ assert.strictEqual(await db.tx(async (t) => await feed.sync(t, 'listing', await row('lst_hidden'))), null, 'never indexed → no tombstone');
++ assert.strictEqual(await db.tx(async (t) => await feed.remove(t, 'listing', 'lst_never')), null);
++ assert.strictEqual((await envelopes()).length, 0);
++ await add('lst_b');
++ await db.tx(async (t) => feed.sync(t, 'listing', await row('lst_b')));
++ await db.exec(sql`UPDATE listings SET status = 'hidden' WHERE id = 'lst_b'`);
++ const gone = await db.tx(async (t) => await feed.sync(t, 'listing', await row('lst_b')));
++ assert.strictEqual(gone.deleted, true);
++ const envs = await envelopes();
++ assert.strictEqual(envs[1].event_type, 'rent.index_document.deleted');
++ assert.deepStrictEqual(searchReads(envs[1]), { owner: 'rent', type: 'listing', id: 'lst_b', revision: 2, deleted: true });
++ assert.strictEqual(await db.tx(async (t) => await feed.remove(t, 'listing', 'lst_b')), null, 'already a tombstone → nothing');
++});
++
++test('noindex and expiry keep the document, marked noindex', async () => {
++ const { db, clock, feed, envelopes, add, row } = await setup();
++ await add('lst_f', { flagged: true });
++ await add('lst_e', { expires_at: clock() - 1000 });
++ await db.tx(async (t) => { await feed.sync(t, 'listing', await row('lst_f')); await feed.sync(t, 'listing', await row('lst_e')); });
++ const [f, e] = (await envelopes()).map(searchReads);
++ assert.strictEqual(f.indexability.decision, 'noindex');
++ assert.strictEqual(e.indexability.decision, 'noindex');
++ assert.ok(e.indexability.reasons.includes('expired'));
++ assert.strictEqual(f.deleted, false);
++});
++
++test('a rolled-back write leaves neither the revision nor the event', async () => {
++ const { db, feed, envelopes, add, row } = await setup();
++ await add('lst_r');
++ await assert.rejects(db.tx(async (t) => { await feed.sync(t, 'listing', await row('lst_r')); throw new Error('the write failed'); }), /the write failed/);
++ assert.strictEqual((await envelopes()).length, 0);
++ assert.strictEqual(await feed.sequencer.current('rent', 'listing', 'lst_r'), null);
++});
++
++test('sweep: every page in id order, only changes re-sent, vanished ids tombstoned, a failing row counted', async () => {
++ const warnings = [];
++ const { db, feed, envelopes, add } = await setup({ log: { warn: (m) => warnings.push(m) } });
++ for (let i = 0; i < 205; i += 1) await add(`lst_${String(i).padStart(3, '0')}`);
++ await add('lst_zzz', { title: 'boom' });
++ let out = await feed.sweep();
++ assert.deepStrictEqual(out, { listing: { seen: 206, sent: 205, removed: 0, failed: 1 } });
++ assert.strictEqual(warnings.length, 1);
++ assert.match(warnings[0], /rent listing lst_zzz: bad row/);
++ assert.strictEqual((await envelopes()).length, 205);
++ out = await feed.sweep();
++ assert.deepStrictEqual(out.listing, { seen: 206, sent: 0, removed: 0, failed: 1 }, 'a second pass sends nothing');
++ await db.exec(sql`DELETE FROM listings WHERE id IN ('lst_003', 'lst_150')`);
++ await db.exec(sql`UPDATE listings SET body = 'Three rooms now.' WHERE id = 'lst_010'`);
++ out = await feed.sweep();
++ assert.deepStrictEqual(out.listing, { seen: 204, sent: 1, removed: 2, failed: 1 });
++ const envs = await envelopes();
++ const tail = envs.slice(-3).map((e) => e.event_type);
++ assert.deepStrictEqual(tail, ['rent.index_document.upserted', 'rent.index_document.deleted', 'rent.index_document.deleted']);
++ assert.deepStrictEqual(envs.slice(-2).map((e) => e.payload.id), ['lst_003', 'lst_150']);
++ out = await feed.sweep();
++ assert.deepStrictEqual(out.listing, { seen: 204, sent: 0, removed: 0, failed: 1 }, 'tombstones are not re-sent');
++});
++
++test('sweep: concurrent calls share one pass', async () => {
++ const { feed, add } = await setup();
++ await add('lst_1');
++ const [a, b] = await Promise.all([feed.sweep(), feed.sweep()]);
++ assert.strictEqual(a, b);
++ assert.strictEqual(a.listing.sent, 1);
++});
++
++test('the factory refuses what it cannot run', async () => {
++ const { db } = await setup();
++ const outbox = { enqueue: async () => {} };
++ const base = { owner: 'rent', db, outbox, baseUrl: 'https://openvibe.rent' };
++ assert.throws(() => createSearchFeed({ ...base, owner: 'Rent!', types: { x: {} } }), /owner/);
++ assert.throws(() => createSearchFeed({ ...base, types: {} }), /types/);
++ assert.throws(() => createSearchFeed({ ...base, types: { listing: { page: () => '/', document: () => ({}) } } }), /rows/);
++ assert.throws(() => createSearchFeed({ ...base, baseUrl: null, types: { listing: { page: () => '/', document: () => ({}), rows: async () => [] } } }), /baseUrl/);
++ const feed = createSearchFeed({ ...base, types: { listing: { page: () => '/', document: () => ({}), rows: async () => [] } } });
++ assert.throws(() => feed.documentOf('job', { id: 'x' }), /unknown document type/);
++});
++
++run();