openvibers/openvibe.publishing CHANGELOG.md

CHANGELOG.md

main 36.2 KB · 493 lines · markdown

1# Changelog
2
3All notable changes to `openvibe-publishing`. Versions follow [semver](https://semver.org/): a
4breaking change to any exported function, table layout, reason code or document shape is a new
5major (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
32changes; a product on v1.5.x upgrades by pinning v1.6.0.
33
34### Added — `openvibe-publishing/blocks`
35
36The rich block model Blog and Wiki share: a post or an article body is Markdown plus blocks, and the
37Markdown stays the source of truth (revisions diff it, and an editor that does not know a block still
38shows 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
68changes; 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
73outbox table (`event_id, topic, payload, created_at, published_at, attempts, last_error, next_attempt_at, claimed_at,
74claimed_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
90Why: Meme, Exchange and Fun queue a document per change into `<product>_outbox` and left the delivery for later;
91118 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`.
99An 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
104changes; 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,
125table layout, reason code or document shape changes; a product on v1.2.0 upgrades by pinning v1.3.0.
126Every option defaults to what the product already renders, so a product that passes none of them is
127byte-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
143code 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`.**
167No existing export, table layout, reason code or document shape changes; a product on v1.0.0 upgrades by
168pinning v1.1.0, and the five content products (News, Reviews, Deals, Coupons, Trade) each delete their
169own 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
174The chassis the five content products each re-implemented (~4,150 lines): primitives and thin factories,
175not 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
213glue 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
215and 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
235not 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;
241production 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
243functions, `ai`, `discussion`'s client) are unchanged. The seven products keep pinning v0.4.x until
244each moves to PostgreSQL in its own change. A database written by 0.4 imports into the 1.0 schema
245with the SDK's `importSqlite` (checked before release on every store: `report.ok`, identities kept,
246sequences 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
378openvibe-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
382Depends on openvibe-shared v1.11.0 (the OpenVibe Frame rename). No API change.
383
384## 0.3.1 — 2026-09-24
385
386Depends 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
405Security 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
422v0.12.0, owned by OpenVibe.Search) instead of the 0.1.0 proposal, and builds the events Search
423actually 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
467First 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