openvibers/openvibe.publishing CHANGELOG.md
CHANGELOG.md
| 1 | # Changelog |
| 2 | |
| 3 | All notable changes to `openvibe-publishing`. Versions follow [semver](https://semver.org/): a |
| 4 | breaking change to any exported function, table layout, reason code or document shape is a new |
| 5 | major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball. |
| 6 | |
| 7 | ## 1.7.1 — 2026-11-10 |
| 8 | |
| 9 | - `blocks.css`: a callout with no title starts at its padding (its first paragraph's top margin pushed the words |
| 10 | down, so the card looked taller above its text than below). |
| 11 | |
| 12 | ## 1.7.0 — 2026-11-09 |
| 13 | |
| 14 | - **`openvibe-publishing/blocks.css`: one stylesheet for every block the library renders** (additive). The look of |
| 15 | an infobox, a timeline, a stat, a quote, a callout, a gallery, a compare, a hero, a section with its reactions, |
| 16 | patch notes, a clip, a video, a link out and a missing picture, on the shared theme tokens only (each with a |
| 17 | fallback) and with no site's classes. A product serves this file and links it wherever it renders a body; it places |
| 18 | blocks (its text measure, an infobox beside a lead) and does not restyle them. Before this the rules lived in Blog |
| 19 | alone, so a wiki article showed its blocks as bare browser markup. |
| 20 | - `test/blocks-css.test.js` fails when a renderer emits a class the sheet has no rule for, when a callout tone has no |
| 21 | rule, when a token has no fallback, or when a site class appears in the sheet. |
| 22 | |
| 23 | ## 1.6.1 — 2026-11-09 |
| 24 | |
| 25 | - `blocks`: a timeline and an infobox have one row per line (1.6.0 made a whole body one row) with list markers |
| 26 | dropped, and a row splits at the first ": " or " — ", so a time such as 10:45 stays whole. A timeline's `datetime` |
| 27 | is set only for a date or a time. An infobox row without a key is a `<dd>`, valid inside its `<dl>`. |
| 28 | |
| 29 | ## 1.6.0 — 2026-11-09 |
| 30 | |
| 31 | **Additive: `openvibe-publishing/blocks`.** No existing export, table layout, reason code or document shape |
| 32 | changes; a product on v1.5.x upgrades by pinning v1.6.0. |
| 33 | |
| 34 | ### Added — `openvibe-publishing/blocks` |
| 35 | |
| 36 | The rich block model Blog and Wiki share: a post or an article body is Markdown plus blocks, and the |
| 37 | Markdown stays the source of truth (revisions diff it, and an editor that does not know a block still |
| 38 | shows its text). |
| 39 | |
| 40 | - `blocks.parse(markdown)` → a plain-JSON tree: `{ type: 'root', children: [...] }`, where a run of prose is |
| 41 | `{ type: 'text', value }` and a block is `{ type, attrs, children }`. A block is a fenced directive, |
| 42 | `:::name{attr="value"}` … `:::`; a one-line block closes itself. Blocks nest (a section holds prose and |
| 43 | other blocks). A `:::` inside a fenced code block is prose, never a directive. |
| 44 | - `blocks.validate(tree)` → `{ ok, errors: [{ path, message }] }`: a missing required attribute, a Media |
| 45 | object id that is not one, a section id that is not a slug or that two sections share, a callout tone |
| 46 | outside info/warn/tip, an embed url that is not http(s). |
| 47 | - `blocks.render(tree, options)` → server-rendered HTML, complete without JavaScript. Every block has a |
| 48 | plain-HTML form: a section's reactions are a `<form>` that posts (signed out, the reader is asked to |
| 49 | sign in), the patch notes are a `<details>` grouped by site with every commit linked, the images are |
| 50 | `<img>` with width, height and alt, and `loading="lazy"` after the first one. The caller resolves the |
| 51 | Media ids (`media`, `urlFor`); the renderer never guesses a URL and a broken object renders as an |
| 52 | explicit "no longer available" placeholder. |
| 53 | - `blocks.toText(tree)` → the plain text of a body: what a feed, a search document and an AI reader get. |
| 54 | - `seo.structuredData.article({ ..., hasPart })` → the sections of the article as WebPageElements, so a reader (and a crawler) sees the parts of a long post. |
| 55 | - `blocks.serialize(tree)` → the Markdown that parses back into the same tree: a caller who has blocks (an editor, an agent) hands Blog the Markdown it will keep, and the Markdown stays the source of truth. |
| 56 | - `blocks.creditLine(credit)` → the credit of an image: an AI image says "Made with <model>", an Openverse |
| 57 | or Commons image keeps its licence and its author (both linked), and nothing known means no credit line |
| 58 | — an empty credit is never a fake one. |
| 59 | - The blocks: hero, section, figure, compare, gallery, clip, pic, video, embed, callout, stat, patchnotes, |
| 60 | infobox, timeline, quote. |
| 61 | - `render(tree, { renderText })`: a caller whose prose is not plain Markdown renders the text parts itself. |
| 62 | - `render()` is safe on its own: an embed whose URL is not http(s) is shown as text, never a link (a caller that skipped |
| 63 | `validate()` could otherwise publish a `javascript:` link). |
| 64 | |
| 65 | ## 1.5.0 — 2026-11-02 |
| 66 | |
| 67 | **Additive: `openvibe-publishing/search-relay`.** No existing export, table layout, reason code or document shape |
| 68 | changes; a product on v1.4.x upgrades by pinning v1.5.0. |
| 69 | |
| 70 | ### Added — `openvibe-publishing/search-relay` |
| 71 | |
| 72 | `createSearchRelay({ db, table, owner, searchUrl, auth, … })` delivers the Search documents a product queued in its own |
| 73 | outbox table (`event_id, topic, payload, created_at, published_at, attempts, last_error, next_attempt_at, claimed_at, |
| 74 | claimed_by`) through OpenVibe.Search's owner API: PUT `/api/v1/documents/:owner/:type/:id` for a document, DELETE with |
| 75 | `?revision=` for a tombstone, with the product's service token (`search.document.write` on `openvibe.search`; `auth` is |
| 76 | `{ authHeaders(), invalidate() }`, e.g. openvibe-contracts `serviceAuth.createTokenClient`). |
| 77 | |
| 78 | - Rows go oldest first, at most `maxPerTick` (100) a tick: the owner API allows a service 120 writes a minute. Within |
| 79 | a tick only a document's newest row is sent; older ones are marked `superseded`. |
| 80 | - 2xx sets `published_at`. 409 `search.stale_revision` is done (Search holds a newer revision). 409 |
| 81 | `search.revision_conflict` is sent once more as the stored revision + 1. |
| 82 | - 5xx, 408, 401 (which drops the token) and network errors retry with backoff, 15 s doubling to an hour. 429 stops |
| 83 | the tick until Retry-After and releases the rest unclaimed, without counting an attempt. |
| 84 | - Any other refusal (a bad document, not the owner) and a payload naming another owner are set aside for good: |
| 85 | `published_at` is set, `last_error` starts `rejected`, and the reason is logged. |
| 86 | - A claim is a five-minute lease (`FOR UPDATE SKIP LOCKED`), so two processes never send one row at once. |
| 87 | - `start()` (a tick five seconds after boot, then every minute), `kick()` (a tick in two seconds), `drain()`, |
| 88 | `stop()`, `status()` (`pending`, totals, last run, last error, pause). Off without `searchUrl` or `auth`. |
| 89 | |
| 90 | Why: Meme, Exchange and Fun queue a document per change into `<product>_outbox` and left the delivery for later; |
| 91 | 118 meme entries and 104 Exchange listings had never reached Search (2026-11-02). |
| 92 | |
| 93 | ## 1.4.1 — 2026-10-22 |
| 94 | |
| 95 | **Fix: `seo.canonicalUrl` keeps a path with several leading slashes on the site.** A request path such as `//feed/` or |
| 96 | `//wp-includes/wlwmanifest.xml` (scanners send them) was read as a protocol-relative URL naming another host, so |
| 97 | `canonicalUrl` threw "path must stay on the origin" and News, Deals and Blog answered 500. A run of leading slashes |
| 98 | (or backslashes) now counts as one: `canonicalUrl('https://openvibe.news', '//feed/')` is `https://openvibe.news/feed`. |
| 99 | An absolute URL on another origin still throws. No export, table layout, reason code or document shape changes. |
| 100 | |
| 101 | ## 1.4.0 — 2026-10-10 |
| 102 | |
| 103 | **Additive: `openvibe-publishing/search-feed`.** No existing export, table layout, reason code or document shape |
| 104 | changes; a product on v1.3.0 upgrades by pinning v1.4.0. |
| 105 | |
| 106 | ### Added — `openvibe-publishing/search-feed` |
| 107 | |
| 108 | - `createSearchFeed({ owner, db, outbox, baseUrl, types, now?, indexnow?, log? })` puts a product's record pages |
| 109 | (listings, job ads, help articles, catalog items, quests) in OpenVibe.Search through the publication glue: one |
| 110 | `search.index-document@1` per row on the caller's transaction handle, revisions from the index sequencer |
| 111 | (`<owner>_index_revisions`), and the product's outbox. Each type gives `page(row)`, `document(row)` (title, summary, |
| 112 | body, facets, dates, authorship, provenance, plus `listed` and `noindex`) and `rows(afterId, limit)`; `exists(ids)` |
| 113 | is optional. |
| 114 | - `sync(t, type, row)` and `remove(t, type, id)` run inside the write's transaction. An unchanged row sends nothing, |
| 115 | `listed: false` is a tombstone, and an id Search never had sends nothing. |
| 116 | - `sweep()` brings Search level with the tables: every row in id order, re-sending only what changed, and a tombstone |
| 117 | for each id whose row is gone (with `exists`). One transaction per row; a failing row is counted and logged and the |
| 118 | pass goes on. Concurrent calls share one pass. |
| 119 | - Indexability follows the page's own robots (`noindex`) and expiry (`expiresAt`), never the publication gate's word |
| 120 | count: a record is not an article. |
| 121 | |
| 122 | ## 1.3.0 — 2026-10-04 |
| 123 | |
| 124 | **Additive: `layout.renderDocument` now forwards the AI summary to the shell.** No existing export, |
| 125 | table layout, reason code or document shape changes; a product on v1.2.0 upgrades by pinning v1.3.0. |
| 126 | Every option defaults to what the product already renders, so a product that passes none of them is |
| 127 | byte-identical. |
| 128 | |
| 129 | ### Changed — `openvibe-publishing/layout` |
| 130 | |
| 131 | - `renderDocument(o)` passes four options through to `shell.page`, which builds them with |
| 132 | `seo.pageSummary`: **`summary`** (one-line AI summary), **`facts`** (`[[key, value], string]` or |
| 133 | `[string]` rows), **`updated`** (ISO date) and **`url`** (absolute; defaults to the document's |
| 134 | canonical). With a summary the document carries `<meta name="ai-summary">` and a `WebPage` JSON-LD |
| 135 | tag in `<head>`, and with facts the `<noscript><section data-ai-summary>` block at the top of |
| 136 | `<body>`; `updated` becomes `dateModified`. Nothing is invented: without a summary none of that is |
| 137 | emitted, an omitted fact or date is left out of the block, and `url` is only ever the caller's or |
| 138 | the canonical's. Styling is unchanged. |
| 139 | |
| 140 | ## 1.2.0 — 2026-10-04 |
| 141 | |
| 142 | **Additive: one new entry point, `openvibe-publishing/layout`.** No existing export, table layout, reason |
| 143 | code or document shape changes; a product on v1.1.0 upgrades by pinning v1.2.0. |
| 144 | `require('openvibe-publishing')` exposes `.layout` lazily. |
| 145 | |
| 146 | ### Added — `openvibe-publishing/layout` |
| 147 | |
| 148 | - `renderDocument(o)` — the page document the seven publication sites (Wiki, Blog, News, Reviews, Deals, |
| 149 | Coupons, Trade) each hand-wrote, composed by `openvibe-shared/shell` `page()` (T11 shell adoption). |
| 150 | The gate's decision stays the robots source: `robots` is `decision.robots` and the canonical is |
| 151 | `decision.canonical || o.canonical`; without a decision (or an explicit `robots`) it throws a |
| 152 | `TypeError`, so no page is ever indexable by default. It adds the publication extras to the shell's |
| 153 | head (article published/modified times, prev/next, feed links, the app icon, the site stylesheet, |
| 154 | shared stylesheets, the site's scripts, the boost marker, referrer, extra head markup), the body frame |
| 155 | (skip link, `#navbar-mount`, header, the no-JavaScript account bar, `<main>`) and the footer init the |
| 156 | shell never emits (`window.__OV_PAGE` + `OpenVibeFooter.init`). Every value reaching markup is escaped; |
| 157 | the boot JSON escapes `<`. Styling is unchanged. Needs `openvibe-shared` ≥ 2.6.0 (`shell`); the other |
| 158 | entry points keep the `>=1.5.0` peer range. |
| 159 | |
| 160 | ### Changed |
| 161 | |
| 162 | - Tests pin `openvibe-shared` v2.6.0 (was v2.5.0). |
| 163 | |
| 164 | ## 1.1.0 — 2026-09-29 |
| 165 | |
| 166 | **Additive: two new entry points, `openvibe-publishing/ingest` and `openvibe-publishing/publication`.** |
| 167 | No existing export, table layout, reason code or document shape changes; a product on v1.0.0 upgrades by |
| 168 | pinning v1.1.0, and the five content products (News, Reviews, Deals, Coupons, Trade) each delete their |
| 169 | own copy of this code when they convert. `require('openvibe-publishing')` exposes `.ingest` and |
| 170 | `.publication` lazily, and root `schema({ … })` gains the `ingest` key. |
| 171 | |
| 172 | ### Added — `openvibe-publishing/ingest` |
| 173 | |
| 174 | The chassis the five content products each re-implemented (~4,150 lines): primitives and thin factories, |
| 175 | not one opinionated pipeline, because their domains diverge. |
| 176 | |
| 177 | - `createSourcesClient({ config, fetchImpl, now, timeoutMs })` — the OpenVibe.Sources client |
| 178 | (`GET /api/v1/items` in change order, `item`, `sources`, `source`) with a client-credentials token for |
| 179 | audience `openvibe.sources` and scopes `sources.item.read`/`sources.source.read`. Failures throw a |
| 180 | `SourcesError` with a stable code (`sources.not_configured`, `sources.token_unavailable`, |
| 181 | `sources.unavailable`, `sources.http_<status>`, `sources.bad_response`); nothing invents an item. |
| 182 | - `createChangeCursor(db, { prefix, now })` — a named change cursor in `<prefix>_ingest_cursor` |
| 183 | (`name`, `cursor`, `updated_at`), `schema()` gives its DDL for the product's migration, |
| 184 | `ensureSchema()` runs it for tests; `get(name)` reads and `set(t, name, value)` advances the cursor on |
| 185 | the caller's handle, monotonically (`GREATEST`). |
| 186 | - `pullChanges({ db, cursor, source, apply, name, maxPages, pageSize, onItem })` — one transaction per |
| 187 | page (the page's writes and the cursor advance commit together), one savepoint per item (an item whose |
| 188 | `apply()` throws is rolled back alone and counted `failed`, so a bad item never stalls its page or the |
| 189 | cursor), and `apply(item, t)` returns `'applied' | 'hold' | 'removed'` (a `{ outcome }` object is |
| 190 | accepted; anything else is isolated as failed). `hold` is not an error: it is counted and the cursor |
| 191 | moves past it (the product records the hold itself). Returns |
| 192 | `{ pages, applied, hold, removed, failed, after }`. |
| 193 | - `createEventConsumer({ db, secrets, consumer, now, table })` — the shared half of `POST /internal/events`: |
| 194 | the `X-OpenVibe-Signature` v2 HMAC (±300 s, v1-only or stale refused), and exactly-once via the SDK's |
| 195 | pg inbox claimed in the same transaction as the handler, so a redelivery changes nothing and a failed |
| 196 | handler rolls back its receipt. `apply(raw, headers, handler) → { status, code?, event_id?, duplicate?, outcome? }`. |
| 197 | - `normalize` — the shared helpers, byte-for-byte from the five copies: News's `fold/titleKey/urlKey/ |
| 198 | hostOf/publisherDomain/shingles/jaccard/stem/terms/entities/licensedSummary/independentSources`, Deals's |
| 199 | `normalizeUrl/hostPathKey/domainOf/slugify/parseAmount/parseCurrency/parseInstant/parseEnum/parseDecimal/ |
| 200 | parseTime/text/longText/slug/tokens/str/httpUrl/json` and its alias normalizers |
| 201 | (`alias/tryAlias/name/url/code/gtin/sourceKey/external`), Reviews's key normalizers and Trade's |
| 202 | time/decimal/string parsers. `test/ingest.test.js` proves all 119 captured cases from the five current |
| 203 | product files (`test/fixtures/normalize.json`, each case citing `file:line`). |
| 204 | - `hosts` — the generic PSL and registrable-host helpers moved out of Coupons |
| 205 | (`normalizeHost/publicSuffix/registrableDomain/registrable/hostOfUrl/normalizePathPrefix/checkRule/ |
| 206 | bestRule`); and `freshness` (`verdict/view`), the generic staleness rule (fresh inside the window, |
| 207 | stale after, never invented), with the threshold a parameter — no policy constant and no date ever |
| 208 | defaults to now. |
| 209 | |
| 210 | ### Added — `openvibe-publishing/publication` |
| 211 | |
| 212 | `createPublication({ owner, sequencer, outbox, baseUrl, indexnow, now, decide, document, page })` — the |
| 213 | glue the five products each re-implemented: gate → document → `sequencer.stamp(t, doc)` → events → |
| 214 | `outbox.enqueue(t, …)`, always on the **caller's** transaction handle, so an index document, its event |
| 215 | and the product's own change commit or roll back together (ADR-004). |
| 216 | |
| 217 | - `index(t, { document, page, traceparent })` — stamps and enqueues one document; an unchanged document |
| 218 | (same revision) is a no-op; a resource that was never indexed sends no tombstone. |
| 219 | - `tombstone(t, { type, id, page, traceparent })` — the tombstone for unpublish/merge. |
| 220 | - `publication(t, { type, action, id, revision, document, decision, actor, extra, traceparent })` — the |
| 221 | product's own `<owner>.<type>.<action>` event. |
| 222 | - `sync(t, entity, { traceparent, forSearch })` — the whole chain for one entity for a product that |
| 223 | supplies `decide`/`document`/`page`. |
| 224 | - It emits exactly `search.index-document@1` carried by `<owner>.index_document.upserted|deleted`, and |
| 225 | the publication events (`index-hooks.publicationEvent`), both validated by openvibe-contracts; and it |
| 226 | owns the IndexNow ping (config stays per product): an indexable page that appeared/changed or a page |
| 227 | Search already had that went away is announced, a draft/private/noindex page never is, and |
| 228 | `pingSoon` is a no-op without a key and never throws. |
| 229 | |
| 230 | ### Tests |
| 231 | |
| 232 | `test/ingest.test.js` and `test/publication.test.js` (new) plus the extended `test/schema.test.js` |
| 233 | (the `<prefix>_ingest_cursor` DDL, idempotent, served by its primary key). |
| 234 | `npm test` (Node 22): 18 files, 135 cases; 17 files and 1 skipped (postgres) when `OV_TEST_PG_URL` is |
| 235 | not set. |
| 236 | |
| 237 | ## 1.0.0 — 2026-09-28 |
| 238 | |
| 239 | **Breaking: the stores run on PostgreSQL through `openvibe-sdk/db`, and every store API is async** |
| 240 | (ADR-035 and its amendment: PostgreSQL 18 is every service's system of record, one SQL dialect; |
| 241 | production connects through PgBouncer in transaction mode, tests run PGlite). The pure modules |
| 242 | (`ssr`, `diff`, the gate and feeds in `seo`, the builders in `index-hooks`, the `authorship` record |
| 243 | functions, `ai`, `discussion`'s client) are unchanged. The seven products keep pinning v0.4.x until |
| 244 | each moves to PostgreSQL in its own change. A database written by 0.4 imports into the 1.0 schema |
| 245 | with the SDK's `importSqlite` (checked before release on every store: `report.ok`, identities kept, |
| 246 | sequences moved past them, purge records still refusing reuse). |
| 247 | |
| 248 | ### Dependencies |
| 249 | - **`openvibe-sdk` ≥ 0.15.0 is a peer dependency** (tests pin the v0.15.0 tag). A peer, not a |
| 250 | dependency, because a store runs its queries on the product's handle: the `sql` fragments it builds |
| 251 | must come from the same copy of the SDK as that handle (the handle recognises its own fragments), |
| 252 | and a service should have one data layer and one pool, not a nested second copy per library. |
| 253 | Stores take the `sql` tag from the handle itself (`db.sql`), and `lib/` never loads a driver. |
| 254 | - `better-sqlite3` is gone (it was a peer dependency and a devDependency). `@electric-sql/pglite` |
| 255 | (`^0.5.8`) and `pg` (`^8.23.0`) are devDependencies; products bring `pg` themselves. |
| 256 | |
| 257 | ### Changed (every store) |
| 258 | - **The handle** is an `openvibe-sdk/db` handle (`createDb`): `query`, `many`, `maybe`, `one`, `value`, |
| 259 | `exec`, `tx` and `sql`. A better-sqlite3 handle throws a TypeError that names 0.4.x. |
| 260 | - **Every method that touches data is `async`** and returns a promise; argument errors that were |
| 261 | synchronous throws are now rejections (the prefix and handle checks of `create…()` still throw). |
| 262 | Method names and result shapes are those of 0.4: ids of identity columns are still JS numbers, |
| 263 | JSON fields still come back as objects, times are still ISO strings built from epoch milliseconds. |
| 264 | - **Transaction handles.** Every data method takes an optional transaction handle as its first |
| 265 | argument: `store.m(args…)` runs on the store's db, `store.m(t, args…)` inside the caller's |
| 266 | `db.tx(async (t) => …)`, so a product's own writes and the stores' commit together or not at all. |
| 267 | Writes that need several statements run in their own `db.tx`, or in a savepoint (`t.tx`) of the |
| 268 | caller's transaction; a store's refusal (412, 404, 410, 400) leaves the caller's transaction usable. |
| 269 | - **No DDL at construction.** Creating a store runs no query. Each storing module exports its DDL, |
| 270 | `schema(prefix)` → SQL text, for the product's migration files (the runtime role behind PgBouncer |
| 271 | cannot create tables); `store.schema()` gives the same text and `await store.ensureSchema()` runs it |
| 272 | (tests, PGlite) and resolves to the store. The root export adds `schema({ module: prefix | [prefixes] })` |
| 273 | joining several. `seo` exports it as `redirectsSchema(prefix)` and `index-hooks` as |
| 274 | `sequencerSchema(prefix)` (their `schema` names were taken by schema.org and the Search contract). |
| 275 | - **Types:** `INTEGER PRIMARY KEY AUTOINCREMENT` → `bigint GENERATED ALWAYS AS IDENTITY`; JSON text → |
| 276 | `jsonb`; integer epoch milliseconds → `bigint` (still milliseconds); citations' `retrieved_at` (ISO |
| 277 | text) → `timestamptz`; counts and revision numbers → `integer`. Identifier columns (`entity_id`, |
| 278 | drafts' `owner`, `media_id`, `source_item_id`, `from_path`, jobs' `id`, the sequencer's key) are |
| 279 | `COLLATE "C"`, so they sort and compare in byte order as SQLite did. Every CHECK constraint is kept. |
| 280 | - **Immutability triggers** are PL/pgSQL (`<table>_guard()` with `BEFORE UPDATE` / `BEFORE DELETE` |
| 281 | row triggers): revisions and citations refuse UPDATE always and DELETE unless the entity is in the |
| 282 | purge table; review-log rows refuse UPDATE. Same messages; the SQLSTATE is 23001 |
| 283 | (restrict_violation). TRUNCATE is not a row operation and is not guarded: the runtime role has no |
| 284 | TRUNCATE privilege, and the owner's one-time import (`importSqlite({ truncate: true })`) needs it. |
| 285 | - **Indexes for every query** (`test/schema.test.js` EXPLAINs each statement the stores send). Some |
| 286 | index names changed because PostgreSQL names must fit 63 bytes at a 40-character prefix (for example |
| 287 | `<prefix>_revisions_entity_number` is now `<prefix>_revisions_entity_num`); new ones: drafts |
| 288 | `(entity_id, updated_at DESC, owner)`, citations `(source_item_id, entity_id, revision, id)` (partial) |
| 289 | and `(carried_from)` (partial, for the self-reference), attachments `(entity_id, position, id)` and |
| 290 | `(media_id, entity_id)`, reviews `(entity_id, id)`, terms `(parent_id, name)` and `(vocabulary, name)`, |
| 291 | term links `(term_id, entity_id)`, jobs `(run_at, id) WHERE status = 'pending'`, |
| 292 | `(lease_until) WHERE status = 'running'` and `(entity_id, run_at, id)`, redirects |
| 293 | `(entity_id, created_at, from_path)`. |
| 294 | - **Concurrency.** SQLite had one writer; PostgreSQL has many. Writers of one entity take a |
| 295 | transaction-scoped advisory lock (`pg_advisory_xact_lock`, allowed behind PgBouncer) where 0.4 |
| 296 | relied on the single writer for a check-then-write: revisions (`create`, `revert`, `commitDraft`, |
| 297 | `purgeEntity`), citations (`attach`, `attachMany`, `carryForward`, `purgeEntity`), `setTerms` (per |
| 298 | entity and vocabulary) and taxonomy tree changes (`setParent`, `remove`). Get-or-create paths use |
| 299 | `ON CONFLICT`. |
| 300 | - **Bounded lists.** Every list method takes a `limit`; lists that were unbounded now have a default |
| 301 | (1000 unless noted) and a maximum (10000). Lists that grow across entities page by keyset (`after`). |
| 302 | **Batch reads** for list routes (no N+1): `revisions.getMany`, `citations.forRevisions`, |
| 303 | `reviews.latestMany`, `taxonomy.termsForMany` (refs are `{ entityId, revision }` and answers come in |
| 304 | the same order). |
| 305 | |
| 306 | ### Changed, per store (beyond `await` and the optional leading `t`) |
| 307 | - **revisions** (`createRevisionStore`): new `exists(entityId, number)` (no content read) and |
| 308 | `getMany(refs)`. `lineage(entityId, number, { limit = 1000 })` is one recursive query and bounded. |
| 309 | `drafts(entityId, { limit = 500 })` is one query (0.4 ran two per draft), ordered by `updated_at` |
| 310 | then owner. `diff()` reads both revisions in one query. `list()`'s `before` must be a number. |
| 311 | `saveDraft()` is one statement. A malformed revision number finds nothing (null) instead of erroring. |
| 312 | - **citations** (`createCitationStore`): new `forRevisions(refs)` and `tables` (`{ citations, purges }`). |
| 313 | `attachMany()` validates every row before the first write and inserts them in one statement; |
| 314 | `carryForward()` is one `INSERT … SELECT` (carried rows are not re-validated: they were valid when |
| 315 | written). `attach()` checks the source, URL and quote before it looks up the revision (0.4 looked up |
| 316 | the revision first). `forRevision(entityId, revision, { limit = 1000 })`, `history(entityId, { limit = 1000 })`, |
| 317 | `bySourceItem(sourceItemId, { limit = 500, after })` (keyset: pass the last citation of a page; 0.4 |
| 318 | returned every row). `retrievedAt` is read from `timestamptz` (same ISO string). |
| 319 | - **media** (`createAttachmentStore`): `list(entityId, { revision, limit = 1000 })`, |
| 320 | `broken(entityId, { limit = 1000 })`, `entitiesUsing(mediaId, { limit = 1000, after })` (keyset on |
| 321 | entity id). `markBroken()` / `markAvailable()` are one `UPDATE … RETURNING` each. `verify()` writes |
| 322 | all verdicts in one statement after every check has answered (0.4 wrote each as it came) and never |
| 323 | holds a transaction across the calls to Media. `detach()` with a malformed id is `false`. |
| 324 | - **discussion** (`createDiscussionRefs`): `get`, `set`, `forget`, `threadFor` are async; `ref` is |
| 325 | `jsonb`; `schema(prefix)` is exported. `threadFor()` calls Community between two statements, never |
| 326 | inside a transaction it opens. `createDiscussionClient` is unchanged. |
| 327 | - **schedule** (`createScheduler`): `claim()` / `runDue()` lease due jobs in one |
| 328 | `UPDATE … FOR UPDATE SKIP LOCKED`, so any number of workers on any number of hosts share one table |
| 329 | without taking a job twice (0.4 serialised claimers with SQLite's `BEGIN IMMEDIATE`); abandoned jobs |
| 330 | are marked in the same statement. `fail()` is one statement (backoff computed in SQL). `runDue()` |
| 331 | reads each finished job from its `RETURNING` row. `jobs(entityId, { limit = 1000 })`. `backoffMs` must |
| 332 | be a list of non-negative integers. |
| 333 | - **authorship** (`createReviewLog`): `record`, `latest`, `history(entityId, { limit = 1000 })` are |
| 334 | async; new `latestMany(refs)`; `schema(prefix)` is exported. The record functions (`record`, |
| 335 | `initialState`, `canPublish`, `gateFacts`, `disclosure`) are unchanged. |
| 336 | - **taxonomy** (`createTaxonomy`): `setTerms()` runs a fixed number of statements whatever the number |
| 337 | of items (one lookup of the ids, one insert of the new names with `ON CONFLICT`, one lookup of the |
| 338 | slugs, one insert of the links), where 0.4 ran several per item; the result and its errors are the |
| 339 | same, and a refused call changes nothing. `ensureTerm()` is safe against a concurrent creator (both |
| 340 | get the one term). `entitiesFor(termId, { includeDescendants, limit = 1000, after })` is one query |
| 341 | and pages by entity id. `terms(vocabulary, { limit = 1000, after })` pages by (name, id); |
| 342 | `children(id, { limit })`, `descendants(id, { limit })` (ordered by name, then id), |
| 343 | `tree(vocabulary, { limit = 10000 })`. New `termsForMany(entityIds, vocabulary)`. |
| 344 | - **seo** (`createRedirectStore`): `recordMove`, `release`, `resolve`, `history(entityId, { limit = 1000 })` |
| 345 | are async; `recordMove()` is one statement; `resolve()` awaits `currentPath(entityId)`, which may |
| 346 | return a promise (a product's own lookup is async now). `redirectsSchema(prefix)` is exported. |
| 347 | - **index-hooks** (`createIndexSequencer`): **`stamp(t, document)` takes the transaction handle |
| 348 | first, always** (like the SDK outbox's `enqueue(t, …)`): the stamped revision must commit if and only |
| 349 | if the event carrying it does, or a replay would believe Search has a document it never received. |
| 350 | Pass `db` only when nothing else is written. `stamp` is one upsert (a second read only when nothing |
| 351 | changed) and safe against concurrent stamps. `current([t,] owner, type, id)` is async. |
| 352 | `sequencerSchema(prefix)` is exported. |
| 353 | - **root** (`require('openvibe-publishing')`): new `schema(stores)`. |
| 354 | |
| 355 | ### Examples and tests |
| 356 | - `examples/two-products`: both apps take an `openvibe-sdk/db` handle, apply |
| 357 | `migrations/0001_initial.sql` (their own tables plus `schema()`, generated by `migrations.js`) with |
| 358 | `db.migrate()`, run related writes in one `db.tx` with `t` passed to every store call, keep the Search |
| 359 | events in an outbox table in that transaction, and build feeds and sitemaps from batch reads. The |
| 360 | servers run on in-memory PGlite. |
| 361 | - Every test runs on PGlite (one instance per file, a fresh schema per test) with the same |
| 362 | assertions as 0.4, adjusted only for types and `await`. New: `schema.test.js` (DDL text, |
| 363 | identifiers within 63 bytes, types, CHECKs, triggers, `db.migrate()`, an index under every |
| 364 | statement), `transactions.test.js` (a caller's `t` spanning its own write and every store's, a |
| 365 | rollback leaving none, a refusal undoing only its savepoint), query-count checks (`drafts()` is one |
| 366 | query, `setTerms()` and `attachMany()` do not grow with their input, a feed of 12 pages costs the same |
| 367 | queries as a feed of 1), and `postgres.test.js`: the stores on PostgreSQL 18 through PgBouncer |
| 368 | (transaction mode) with real concurrency (racing writers of one revision, a purge racing writes, |
| 369 | racing workers, racing get-or-create and `setTerms`, callers' transactions), which runs when |
| 370 | `OV_TEST_PG_URL` is set and otherwise prints `postgresql+pgbouncer: skipped (…)`. CI starts the |
| 371 | containers (`eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test`). |
| 372 | - `npm test` (Node 22.22.1): 16/16 test files with the containers, 121 cases (113 on PGlite or |
| 373 | pure, 8 through PgBouncer; 0.4 had 92 in 13 files); without the containers 15/16 files pass and |
| 374 | `postgres.test.js` is listed as skipped. |
| 375 | |
| 376 | ## 0.4.0 — 2026-09-24 |
| 377 | |
| 378 | openvibe-shared is now an optional **peer dependency** (`>=1.5.0`), resolved from the product's own install and never fetched from the npm registry (where it is not published), the way `better-sqlite3` already was. Until now every Shared release forced a Publishing release; without one, each product installed a second, nested copy of openvibe-shared. Products must keep openvibe-shared in their own dependencies (every OpenVibe product does). No API change. |
| 379 | |
| 380 | ## 0.3.2 — 2026-09-24 |
| 381 | |
| 382 | Depends on openvibe-shared v1.11.0 (the OpenVibe Frame rename). No API change. |
| 383 | |
| 384 | ## 0.3.1 — 2026-09-24 |
| 385 | |
| 386 | Depends on openvibe-shared v1.10.0 (was v1.5.1). Every product pinning the same Shared release now installs one copy instead of a nested one. No API change. |
| 387 | |
| 388 | ## 0.3.0 — 2026-09-24 |
| 389 | |
| 390 | - `openvibe-publishing/ai`: how a content product asks OpenVibe.AI for a draft. `createAiClient({ |
| 391 | baseUrl, tokenClient })` runs a registered workflow (`POST /api/v1/runs?wait=`, polling a run still |
| 392 | going), reads its citations, and returns the output with the `{ id, version, runId, model }` an |
| 393 | AI authorship record needs. Failures throw `AiRunError` with a code (`ai.not_configured`, |
| 394 | `ai.refused`, `ai.run_failed`, `ai.timeout`, `ai.unreachable`), never a partial draft; a refused |
| 395 | token is invalidated and retried once; a traceparent passed in goes along. Additive. |
| 396 | |
| 397 | ## 0.2.2 — 2026-09-24 |
| 398 | |
| 399 | - `openvibe-shared` v1.0.0 -> v1.5.1 (the runtime dependency; `seo` is unchanged, so consumers that |
| 400 | pin a newer openvibe-shared share one copy once this is tagged). Tests use `openvibe-contracts` |
| 401 | v0.33.0 (devDependency). |
| 402 | |
| 403 | ## 0.2.1 — 2026-09-23 |
| 404 | |
| 405 | Security fix (no API change). |
| 406 | |
| 407 | ### Fixed |
| 408 | - **ReDoS in `ssr.renderMarkdown` and `ssr.markdownToText`.** Several patterns backtracked |
| 409 | super-linearly on untrusted Markdown, blocking the event loop on every render of the page: |
| 410 | a heading line with a long run of trailing spaces (`# a` + 4,000 spaces + `x` took 28 s, cubic), |
| 411 | unclosed `**`/`__`/`~~` openers (200 KB took 8 s each), backtick runs of rising length (26 s), |
| 412 | a fence line with trailing spaces before a non-word character (quadratic), and `[` runs in |
| 413 | `markdownToText` (quadratic). Headings, fences, emphasis and code spans are now matched in |
| 414 | linear (code spans O(n log n)) time with the same output (checked by differential fuzzing |
| 415 | against 0.2.0). One visible change: `markdownToText` no longer treats a `[` inside link text as |
| 416 | part of it (`[a [b](u) c` → `[a b c`, was `a [b c`). Products that render user Markdown with |
| 417 | this module should move to 0.2.1. |
| 418 | |
| 419 | ## 0.2.0 — 2026-09-22 |
| 420 | |
| 421 | **Breaking: `index-hooks` now emits the released `search.index-document@1`** (openvibe-contracts |
| 422 | v0.12.0, owned by OpenVibe.Search) instead of the 0.1.0 proposal, and builds the events Search |
| 423 | actually consumes. Products on 0.1.0 must update their calls; nothing else changed. |
| 424 | |
| 425 | ### Changed (breaking) |
| 426 | - `buildIndexDocument()` takes `owner` (was `service`) and returns exactly the contract shape: |
| 427 | `owner`, `type`, `id`, `revision`, `deleted`, `visibility`, `acl`, `canonical_url`, `title`, |
| 428 | `summary`, `body`, `facets`, `language` (was `locale`), `authorship`, `provenance`, |
| 429 | `publication_state`, `published_at`, `updated_at`, `indexability { decision, reasons }`. |
| 430 | Gone: `schema`, `owner_service`, `resource_type`, `resource_id`, `acl.public`, `body_truncated`, |
| 431 | the `indexability.indexable/listable/gate` fields and the provenance object. |
| 432 | - Mappings onto the contract enums: visibility `gated` → `members`; authorship `hybrid` → |
| 433 | `ai_assisted`, `ai` → `ai_generated`; gate reason codes → Search's known reasons where one exists |
| 434 | (`thin` → `thin_content`, `ai_generated_unreviewed` → `ai_unreviewed`, `unreviewed_sensitive` → |
| 435 | `sensitive_unreviewed`, `gated` → `members_only`, `unpublished` → `not_published`, |
| 436 | `missing_canonical` → `missing_canonical_url`, `noindex_requested` → `owner_decision`; the rest |
| 437 | keep the gate's code). `provenance` is now an array of typed references built from citations |
| 438 | (Sources items, or the owner's own citation records), the AI run (with `stub: true` for stub |
| 439 | output) and extra `provenance` refs. |
| 440 | - `tombstone()` returns exactly `{ owner, type, id, revision, deleted: true }`. Anything not |
| 441 | published (draft, scheduled, unpublished, retracted, archived, deleted) is still a tombstone, and |
| 442 | so is unlisted content unless `includeUnlisted: true` (then `visibility: 'unlisted'`). |
| 443 | - ACL follows the contract: `subjects` are `usr_`/`gst_` only, plus `groups` and `entitlements`; |
| 444 | private needs subjects, members needs at least one of the three. Text is clipped to the contract's |
| 445 | limits (title 500, summary 4000, body 48000); facets are checked against its rules. |
| 446 | - `publicationEvent()` (the product's own `<product>.<type>.<action>` event) no longer embeds the |
| 447 | document; its payload is `{ canonical_url, publication_state, indexability }`. |
| 448 | |
| 449 | ### Added |
| 450 | - `indexEvent({ document })`: `<owner>.index_document.upserted` (payload = document) or |
| 451 | `<owner>.index_document.deleted` (payload = `{ type, id, revision }`), subject |
| 452 | `{ type, id, revision }`, visibility internal — the envelopes OpenVibe.Search's webhook consumes. |
| 453 | - `createIndexSequencer(db, { prefix })`: a monotonic index revision per resource in |
| 454 | `<prefix>_index_revisions`. Search refuses a different document at an equal revision and lets a |
| 455 | tombstone win ties, so every indexed change (content, visibility, state, canonical URL, |
| 456 | decision) needs a higher revision; an unchanged document keeps its revision (a safe replay). |
| 457 | - `searchIndexability(decision)`, `SEARCH_REASONS`, `AUTHORSHIP`, `LIMITS`. |
| 458 | - Tests validate every emitted document with `validate('search.index-document@1')` and every |
| 459 | envelope with `validate('events.event-envelope@1')` from openvibe-contracts v0.12.0, and mirror |
| 460 | Search's webhook checks (owner = source, subject matches the payload). |
| 461 | |
| 462 | ### Removed |
| 463 | - `docs/contracts-proposal/search.index-document.v1.json` (superseded by the released contract). |
| 464 | |
| 465 | ## 0.1.0 — 2026-09-22 |
| 466 | |
| 467 | First version (roadmap Wave 15). Packages only: no runtime, no domain, no database. |
| 468 | |
| 469 | ### Added |
| 470 | - `revisions`: drafts, immutable revisions with parent pointers (UPDATE/DELETE blocked by SQLite |
| 471 | triggers), optimistic concurrency with a 412 `revision.conflict`, line and word diffs (Myers, with |
| 472 | a bounded fallback), revert as a new revision, audited `purgeEntity`. |
| 473 | - `schedule`: idempotent publish/unpublish jobs with leases; re-run safe after a worker crash; |
| 474 | retry with backoff; abandoned after `maxAttempts`; injectable clock. |
| 475 | - `taxonomy`: slugs, get-or-create terms per vocabulary, hierarchical categories, entity links. |
| 476 | - `citations`: append-only source references per revision (Sources item id / URL, retrieved_at, |
| 477 | quote span, license note), `carryForward`, lookups by source item. |
| 478 | - `media`: attachments by Media object id with `unverified | available | broken` states and an |
| 479 | explicit broken placeholder in `figureHtml`. |
| 480 | - `discussion`: Community thread resolution (`POST /api/v1/comments/threads/resolve`) with a |
| 481 | service token; stores thread ids only. |
| 482 | - `seo`: the deterministic indexability gate (19 stable reason codes, `hidden`/`noindex` effects), |
| 483 | canonical URL builder, history-aware redirects, meta/robots tags, sitemaps, RSS 2.0, Atom 1.0, |
| 484 | JSON Feed 1.1, JSON-LD builders (Article/BlogPosting/NewsArticle, Review, Product/Offer, |
| 485 | AggregateRating, BreadcrumbList, WebPage) that omit anything not provided. |
| 486 | - `authorship`: human / ai / hybrid / imported records with AI workflow + run references, |
| 487 | disclosure labels, append-only human review log; AI content starts as draft + noindex. |
| 488 | - `index-hooks`: Search index documents and tombstones, `actionFor` transitions, and |
| 489 | `<product>.<type>.<action>` event envelopes valid against `events.event-envelope@1`. |
| 490 | - `ssr`: escaping templates, safe Markdown subset, pagination, breadcrumbs, diff markup, `<time>`. |
| 491 | - `examples/two-products`: a mini wiki and a mini blog with separate databases (the exit proof). |
| 492 | - Proposed contract `docs/contracts-proposal/search.index-document.v1.json`. |
| 493 |