openvibers/openvibe.publishing CHANGELOG.md
CHANGELOG.md
Changelog
All notable changes to openvibe-publishing. Versions follow semver: a
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.7.1 — 2026-11-10
blocks.css: a callout with no title starts at its padding (its first paragraph's top margin pushed the words
down, so the card looked taller above its text than below).
1.7.0 — 2026-11-09
openvibe-publishing/blocks.css: one stylesheet for every block the library renders (additive). The look of
an infobox, a timeline, a stat, a quote, a callout, a gallery, a compare, a hero, a section with its reactions,
patch notes, a clip, a video, a link out and a missing picture, on the shared theme tokens only (each with a
fallback) and with no site's classes. A product serves this file and links it wherever it renders a body; it places
blocks (its text measure, an infobox beside a lead) and does not restyle them. Before this the rules lived in Blog
alone, so a wiki article showed its blocks as bare browser markup.test/blocks-css.test.jsfails when a renderer emits a class the sheet has no rule for, when a callout tone has no
rule, when a token has no fallback, or when a site class appears in the sheet.
1.6.1 — 2026-11-09
blocks: a timeline and an infobox have one row per line (1.6.0 made a whole body one row) with list markers
dropped, and a row splits at the first ": " or " — ", so a time such as 10:45 stays whole. A timeline'sdatetime
is set only for a date or a time. An infobox row without a key is a<dd>, valid inside its<dl>.
1.6.0 — 2026-11-09
Additive: openvibe-publishing/blocks. No existing export, table layout, reason code or document shape
changes; a product on v1.5.x upgrades by pinning v1.6.0.
Added — openvibe-publishing/blocks
The rich block model Blog and Wiki share: a post or an article body is Markdown plus blocks, and the
Markdown stays the source of truth (revisions diff it, and an editor that does not know a block still
shows its text).
blocks.parse(markdown)→ a plain-JSON tree:{ type: 'root', children: [...] }, where a run of prose is
{ type: 'text', value }and a block is{ type, attrs, children }. A block is a fenced directive,
:::name{attr="value"}…:::; a one-line block closes itself. Blocks nest (a section holds prose and
other blocks). A:::inside a fenced code block is prose, never a directive.blocks.validate(tree)→{ ok, errors: [{ path, message }] }: a missing required attribute, a Media
object id that is not one, a section id that is not a slug or that two sections share, a callout tone
outside info/warn/tip, an embed url that is not http(s).blocks.render(tree, options)→ server-rendered HTML, complete without JavaScript. Every block has a
plain-HTML form: a section's reactions are a<form>that posts (signed out, the reader is asked to
sign in), the patch notes are a<details>grouped by site with every commit linked, the images are
<img>with width, height and alt, andloading="lazy"after the first one. The caller resolves the
Media ids (media,urlFor); the renderer never guesses a URL and a broken object renders as an
explicit "no longer available" placeholder.blocks.toText(tree)→ the plain text of a body: what a feed, a search document and an AI reader get.seo.structuredData.article({ ..., hasPart })→ the sections of the article as WebPageElements, so a reader (and a crawler) sees the parts of a long post.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.blocks.creditLine(credit)→ the credit of an image: an AI image says "Made with <model>", an Openverse
or Commons image keeps its licence and its author (both linked), and nothing known means no credit line
— an empty credit is never a fake one.- The blocks: hero, section, figure, compare, gallery, clip, pic, video, embed, callout, stat, patchnotes,
infobox, timeline, quote. render(tree, { renderText }): a caller whose prose is not plain Markdown renders the text parts itself.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
validate()could otherwise publish ajavascript:link).
1.5.0 — 2026-11-02
Additive: openvibe-publishing/search-relay. No existing export, table layout, reason code or document shape
changes; a product on v1.4.x upgrades by pinning v1.5.0.
Added — openvibe-publishing/search-relay
createSearchRelay({ db, table, owner, searchUrl, auth, … }) delivers the Search documents a product queued in its own
outbox table (event_id, topic, payload, created_at, published_at, attempts, last_error, next_attempt_at, claimed_at,
claimed_by) through OpenVibe.Search's owner API: PUT /api/v1/documents/:owner/:type/:id for a document, DELETE with
?revision= for a tombstone, with the product's service token (search.document.write on openvibe.search; auth is
{ authHeaders(), invalidate() }, e.g. openvibe-contracts serviceAuth.createTokenClient).
- Rows go oldest first, at most
maxPerTick(100) a tick: the owner API allows a service 120 writes a minute. Within
a tick only a document's newest row is sent; older ones are markedsuperseded. - 2xx sets
published_at. 409search.stale_revisionis done (Search holds a newer revision). 409
search.revision_conflictis sent once more as the stored revision + 1. - 5xx, 408, 401 (which drops the token) and network errors retry with backoff, 15 s doubling to an hour. 429 stops
the tick until Retry-After and releases the rest unclaimed, without counting an attempt. - Any other refusal (a bad document, not the owner) and a payload naming another owner are set aside for good:
published_atis set,last_errorstartsrejected, and the reason is logged. - A claim is a five-minute lease (
FOR UPDATE SKIP LOCKED), so two processes never send one row at once. start()(a tick five seconds after boot, then every minute),kick()(a tick in two seconds),drain(),
stop(),status()(pending, totals, last run, last error, pause). Off withoutsearchUrlorauth.
Why: Meme, Exchange and Fun queue a document per change into <product>_outbox and left the delivery for later;
118 meme entries and 104 Exchange listings had never reached Search (2026-11-02).
1.4.1 — 2026-10-22
Fix: seo.canonicalUrl keeps a path with several leading slashes on the site. A request path such as //feed/ or
//wp-includes/wlwmanifest.xml (scanners send them) was read as a protocol-relative URL naming another host, so
canonicalUrl threw "path must stay on the origin" and News, Deals and Blog answered 500. A run of leading slashes
(or backslashes) now counts as one: canonicalUrl('https://openvibe.news', '//feed/') is https://openvibe.news/feed.
An absolute URL on another origin still throws. No export, table layout, reason code or document shape changes.
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@1per row on the caller's transaction handle, revisions from the index sequencer
(<owner>_index_revisions), and the product's outbox. Each type givespage(row),document(row)(title, summary,
body, facets, dates, authorship, provenance, pluslistedandnoindex) androws(afterId, limit);exists(ids)
is optional.sync(t, type, row)andremove(t, type, id)run inside the write's transaction. An unchanged row sends nothing,
listed: falseis 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 (withexists). 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,
table layout, reason code or document shape changes; a product on v1.2.0 upgrades by pinning v1.3.0.
Every option defaults to what the product already renders, so a product that passes none of them is
byte-identical.
Changed — openvibe-publishing/layout
renderDocument(o)passes four options through toshell.page, which builds them with
seo.pageSummary:summary(one-line AI summary),facts([[key, value], string]or
[string]rows),updated(ISO date) andurl(absolute; defaults to the document's
canonical). With a summary the document carries<meta name="ai-summary">and aWebPageJSON-LD
tag in<head>, and with facts the<noscript><section data-ai-summary>block at the top of
<body>;updatedbecomesdateModified. Nothing is invented: without a summary none of that is
emitted, an omitted fact or date is left out of the block, andurlis only ever the caller's or
the canonical's. Styling is unchanged.
1.2.0 — 2026-10-04
Additive: one new entry point, openvibe-publishing/layout. No existing export, table layout, reason
code or document shape changes; a product on v1.1.0 upgrades by pinning v1.2.0.
require('openvibe-publishing') exposes .layout lazily.
Added — openvibe-publishing/layout
renderDocument(o)— the page document the seven publication sites (Wiki, Blog, News, Reviews, Deals,
Coupons, Trade) each hand-wrote, composed byopenvibe-shared/shellpage()(T11 shell adoption).
The gate's decision stays the robots source:robotsisdecision.robotsand the canonical is
decision.canonical || o.canonical; without a decision (or an explicitrobots) it throws a
TypeError, so no page is ever indexable by default. It adds the publication extras to the shell's
head (article published/modified times, prev/next, feed links, the app icon, the site stylesheet,
shared stylesheets, the site's scripts, the boost marker, referrer, extra head markup), the body frame
(skip link,#navbar-mount, header, the no-JavaScript account bar,<main>) and the footer init the
shell never emits (window.__OV_PAGE+OpenVibeFooter.init). Every value reaching markup is escaped;
the boot JSON escapes<. Styling is unchanged. Needsopenvibe-shared≥ 2.6.0 (shell); the other
entry points keep the>=1.5.0peer range.
Changed
- Tests pin
openvibe-sharedv2.6.0 (was v2.5.0).
1.1.0 — 2026-09-29
Additive: two new entry points, openvibe-publishing/ingest and openvibe-publishing/publication.
No existing export, table layout, reason code or document shape changes; a product on v1.0.0 upgrades by
pinning v1.1.0, and the five content products (News, Reviews, Deals, Coupons, Trade) each delete their
own copy of this code when they convert. require('openvibe-publishing') exposes .ingest and
.publication lazily, and root schema({ … }) gains the ingest key.
Added — openvibe-publishing/ingest
The chassis the five content products each re-implemented (~4,150 lines): primitives and thin factories,
not one opinionated pipeline, because their domains diverge.
createSourcesClient({ config, fetchImpl, now, timeoutMs })— the OpenVibe.Sources client
(GET /api/v1/itemsin change order,item,sources,source) with a client-credentials token for
audienceopenvibe.sourcesand scopessources.item.read/sources.source.read. Failures throw a
SourcesErrorwith a stable code (sources.not_configured,sources.token_unavailable,
sources.unavailable,sources.http_<status>,sources.bad_response); nothing invents an item.createChangeCursor(db, { prefix, now })— a named change cursor in<prefix>_ingest_cursor
(name,cursor,updated_at),schema()gives its DDL for the product's migration,
ensureSchema()runs it for tests;get(name)reads andset(t, name, value)advances the cursor on
the caller's handle, monotonically (GREATEST).pullChanges({ db, cursor, source, apply, name, maxPages, pageSize, onItem })— one transaction per
page (the page's writes and the cursor advance commit together), one savepoint per item (an item whose
apply()throws is rolled back alone and countedfailed, so a bad item never stalls its page or the
cursor), andapply(item, t)returns'applied' | 'hold' | 'removed'(a{ outcome }object is
accepted; anything else is isolated as failed).holdis not an error: it is counted and the cursor
moves past it (the product records the hold itself). Returns
{ pages, applied, hold, removed, failed, after }.createEventConsumer({ db, secrets, consumer, now, table })— the shared half ofPOST /internal/events:
theX-OpenVibe-Signaturev2 HMAC (±300 s, v1-only or stale refused), and exactly-once via the SDK's
pg inbox claimed in the same transaction as the handler, so a redelivery changes nothing and a failed
handler rolls back its receipt.apply(raw, headers, handler) → { status, code?, event_id?, duplicate?, outcome? }.normalize— the shared helpers, byte-for-byte from the five copies: News'sfold/titleKey/urlKey/ hostOf/publisherDomain/shingles/jaccard/stem/terms/entities/licensedSummary/independentSources, Deals's
normalizeUrl/hostPathKey/domainOf/slugify/parseAmount/parseCurrency/parseInstant/parseEnum/parseDecimal/ parseTime/text/longText/slug/tokens/str/httpUrl/jsonand its alias normalizers
(alias/tryAlias/name/url/code/gtin/sourceKey/external), Reviews's key normalizers and Trade's
time/decimal/string parsers.test/ingest.test.jsproves all 119 captured cases from the five current
product files (test/fixtures/normalize.json, each case citingfile:line).hosts— the generic PSL and registrable-host helpers moved out of Coupons
(normalizeHost/publicSuffix/registrableDomain/registrable/hostOfUrl/normalizePathPrefix/checkRule/ bestRule); andfreshness(verdict/view), the generic staleness rule (fresh inside the window,
stale after, never invented), with the threshold a parameter — no policy constant and no date ever
defaults to now.
Added — openvibe-publishing/publication
createPublication({ owner, sequencer, outbox, baseUrl, indexnow, now, decide, document, page }) — the
glue the five products each re-implemented: gate → document → sequencer.stamp(t, doc) → events →
outbox.enqueue(t, …), always on the caller's transaction handle, so an index document, its event
and the product's own change commit or roll back together (ADR-004).
index(t, { document, page, traceparent })— stamps and enqueues one document; an unchanged document
(same revision) is a no-op; a resource that was never indexed sends no tombstone.tombstone(t, { type, id, page, traceparent })— the tombstone for unpublish/merge.publication(t, { type, action, id, revision, document, decision, actor, extra, traceparent })— the
product's own<owner>.<type>.<action>event.sync(t, entity, { traceparent, forSearch })— the whole chain for one entity for a product that
suppliesdecide/document/page.- It emits exactly
search.index-document@1carried by<owner>.index_document.upserted|deleted, and
the publication events (index-hooks.publicationEvent), both validated by openvibe-contracts; and it
owns the IndexNow ping (config stays per product): an indexable page that appeared/changed or a page
Search already had that went away is announced, a draft/private/noindex page never is, and
pingSoonis a no-op without a key and never throws.
Tests
test/ingest.test.js and test/publication.test.js (new) plus the extended test/schema.test.js
(the <prefix>_ingest_cursor DDL, idempotent, served by its primary key).
npm test (Node 22): 18 files, 135 cases; 17 files and 1 skipped (postgres) when OV_TEST_PG_URL is
not set.
1.0.0 — 2026-09-28
Breaking: the stores run on PostgreSQL through openvibe-sdk/db, and every store API is async
(ADR-035 and its amendment: PostgreSQL 18 is every service's system of record, one SQL dialect;
production connects through PgBouncer in transaction mode, tests run PGlite). The pure modules
(ssr, diff, the gate and feeds in seo, the builders in index-hooks, the authorship record
functions, ai, discussion's client) are unchanged. The seven products keep pinning v0.4.x until
each moves to PostgreSQL in its own change. A database written by 0.4 imports into the 1.0 schema
with the SDK's importSqlite (checked before release on every store: report.ok, identities kept,
sequences moved past them, purge records still refusing reuse).
Dependencies
openvibe-sdk≥ 0.15.0 is a peer dependency (tests pin the v0.15.0 tag). A peer, not a
dependency, because a store runs its queries on the product's handle: thesqlfragments it builds
must come from the same copy of the SDK as that handle (the handle recognises its own fragments),
and a service should have one data layer and one pool, not a nested second copy per library.
Stores take thesqltag from the handle itself (db.sql), andlib/never loads a driver.better-sqlite3is gone (it was a peer dependency and a devDependency).@electric-sql/pglite
(^0.5.8) andpg(^8.23.0) are devDependencies; products bringpgthemselves.
Changed (every store)
- The handle is an
openvibe-sdk/dbhandle (createDb):query,many,maybe,one,value,
exec,txandsql. A better-sqlite3 handle throws a TypeError that names 0.4.x. - Every method that touches data is
asyncand returns a promise; argument errors that were
synchronous throws are now rejections (the prefix and handle checks ofcreate…()still throw).
Method names and result shapes are those of 0.4: ids of identity columns are still JS numbers,
JSON fields still come back as objects, times are still ISO strings built from epoch milliseconds. - Transaction handles. Every data method takes an optional transaction handle as its first
argument:store.m(args…)runs on the store's db,store.m(t, args…)inside the caller's
db.tx(async (t) => …), so a product's own writes and the stores' commit together or not at all.
Writes that need several statements run in their owndb.tx, or in a savepoint (t.tx) of the
caller's transaction; a store's refusal (412, 404, 410, 400) leaves the caller's transaction usable. - No DDL at construction. Creating a store runs no query. Each storing module exports its DDL,
schema(prefix)→ SQL text, for the product's migration files (the runtime role behind PgBouncer
cannot create tables);store.schema()gives the same text andawait store.ensureSchema()runs it
(tests, PGlite) and resolves to the store. The root export addsschema({ module: prefix | [prefixes] })
joining several.seoexports it asredirectsSchema(prefix)andindex-hooksas
sequencerSchema(prefix)(theirschemanames were taken by schema.org and the Search contract). - Types:
INTEGER PRIMARY KEY AUTOINCREMENT→bigint GENERATED ALWAYS AS IDENTITY; JSON text →
jsonb; integer epoch milliseconds →bigint(still milliseconds); citations'retrieved_at(ISO
text) →timestamptz; counts and revision numbers →integer. Identifier columns (entity_id,
drafts'owner,media_id,source_item_id,from_path, jobs'id, the sequencer's key) are
COLLATE "C", so they sort and compare in byte order as SQLite did. Every CHECK constraint is kept. - Immutability triggers are PL/pgSQL (
<table>_guard()withBEFORE UPDATE/BEFORE DELETE
row triggers): revisions and citations refuse UPDATE always and DELETE unless the entity is in the
purge table; review-log rows refuse UPDATE. Same messages; the SQLSTATE is 23001
(restrict_violation). TRUNCATE is not a row operation and is not guarded: the runtime role has no
TRUNCATE privilege, and the owner's one-time import (importSqlite({ truncate: true })) needs it. - Indexes for every query (
test/schema.test.jsEXPLAINs each statement the stores send). Some
index names changed because PostgreSQL names must fit 63 bytes at a 40-character prefix (for example
<prefix>_revisions_entity_numberis now<prefix>_revisions_entity_num); new ones: drafts
(entity_id, updated_at DESC, owner), citations(source_item_id, entity_id, revision, id)(partial)
and(carried_from)(partial, for the self-reference), attachments(entity_id, position, id)and
(media_id, entity_id), reviews(entity_id, id), terms(parent_id, name)and(vocabulary, name),
term links(term_id, entity_id), jobs(run_at, id) WHERE status = 'pending',
(lease_until) WHERE status = 'running'and(entity_id, run_at, id), redirects
(entity_id, created_at, from_path). - Concurrency. SQLite had one writer; PostgreSQL has many. Writers of one entity take a
transaction-scoped advisory lock (pg_advisory_xact_lock, allowed behind PgBouncer) where 0.4
relied on the single writer for a check-then-write: revisions (create,revert,commitDraft,
purgeEntity), citations (attach,attachMany,carryForward,purgeEntity),setTerms(per
entity and vocabulary) and taxonomy tree changes (setParent,remove). Get-or-create paths use
ON CONFLICT. - Bounded lists. Every list method takes a
limit; lists that were unbounded now have a default
(1000 unless noted) and a maximum (10000). Lists that grow across entities page by keyset (after).
Batch reads for list routes (no N+1):revisions.getMany,citations.forRevisions,
reviews.latestMany,taxonomy.termsForMany(refs are{ entityId, revision }and answers come in
the same order).
Changed, per store (beyond await and the optional leading t)
- revisions (
createRevisionStore): newexists(entityId, number)(no content read) and
getMany(refs).lineage(entityId, number, { limit = 1000 })is one recursive query and bounded.
drafts(entityId, { limit = 500 })is one query (0.4 ran two per draft), ordered byupdated_at
then owner.diff()reads both revisions in one query.list()'sbeforemust be a number.
saveDraft()is one statement. A malformed revision number finds nothing (null) instead of erroring. - citations (
createCitationStore): newforRevisions(refs)andtables({ citations, purges }).
attachMany()validates every row before the first write and inserts them in one statement;
carryForward()is oneINSERT … SELECT(carried rows are not re-validated: they were valid when
written).attach()checks the source, URL and quote before it looks up the revision (0.4 looked up
the revision first).forRevision(entityId, revision, { limit = 1000 }),history(entityId, { limit = 1000 }),
bySourceItem(sourceItemId, { limit = 500, after })(keyset: pass the last citation of a page; 0.4
returned every row).retrievedAtis read fromtimestamptz(same ISO string). - media (
createAttachmentStore):list(entityId, { revision, limit = 1000 }),
broken(entityId, { limit = 1000 }),entitiesUsing(mediaId, { limit = 1000, after })(keyset on
entity id).markBroken()/markAvailable()are oneUPDATE … RETURNINGeach.verify()writes
all verdicts in one statement after every check has answered (0.4 wrote each as it came) and never
holds a transaction across the calls to Media.detach()with a malformed id isfalse. - discussion (
createDiscussionRefs):get,set,forget,threadForare async;refis
jsonb;schema(prefix)is exported.threadFor()calls Community between two statements, never
inside a transaction it opens.createDiscussionClientis unchanged. - schedule (
createScheduler):claim()/runDue()lease due jobs in one
UPDATE … FOR UPDATE SKIP LOCKED, so any number of workers on any number of hosts share one table
without taking a job twice (0.4 serialised claimers with SQLite'sBEGIN IMMEDIATE); abandoned jobs
are marked in the same statement.fail()is one statement (backoff computed in SQL).runDue()
reads each finished job from itsRETURNINGrow.jobs(entityId, { limit = 1000 }).backoffMsmust
be a list of non-negative integers. - authorship (
createReviewLog):record,latest,history(entityId, { limit = 1000 })are
async; newlatestMany(refs);schema(prefix)is exported. The record functions (record,
initialState,canPublish,gateFacts,disclosure) are unchanged. - taxonomy (
createTaxonomy):setTerms()runs a fixed number of statements whatever the number
of items (one lookup of the ids, one insert of the new names withON CONFLICT, one lookup of the
slugs, one insert of the links), where 0.4 ran several per item; the result and its errors are the
same, and a refused call changes nothing.ensureTerm()is safe against a concurrent creator (both
get the one term).entitiesFor(termId, { includeDescendants, limit = 1000, after })is one query
and pages by entity id.terms(vocabulary, { limit = 1000, after })pages by (name, id);
children(id, { limit }),descendants(id, { limit })(ordered by name, then id),
tree(vocabulary, { limit = 10000 }). NewtermsForMany(entityIds, vocabulary). - seo (
createRedirectStore):recordMove,release,resolve,history(entityId, { limit = 1000 })
are async;recordMove()is one statement;resolve()awaitscurrentPath(entityId), which may
return a promise (a product's own lookup is async now).redirectsSchema(prefix)is exported. - index-hooks (
createIndexSequencer):stamp(t, document)takes the transaction handle
first, always (like the SDK outbox'senqueue(t, …)): the stamped revision must commit if and only
if the event carrying it does, or a replay would believe Search has a document it never received.
Passdbonly when nothing else is written.stampis one upsert (a second read only when nothing
changed) and safe against concurrent stamps.current([t,] owner, type, id)is async.
sequencerSchema(prefix)is exported. - root (
require('openvibe-publishing')): newschema(stores).
Examples and tests
examples/two-products: both apps take anopenvibe-sdk/dbhandle, apply
migrations/0001_initial.sql(their own tables plusschema(), generated bymigrations.js) with
db.migrate(), run related writes in onedb.txwithtpassed to every store call, keep the Search
events in an outbox table in that transaction, and build feeds and sitemaps from batch reads. The
servers run on in-memory PGlite.- Every test runs on PGlite (one instance per file, a fresh schema per test) with the same
assertions as 0.4, adjusted only for types andawait. New:schema.test.js(DDL text,
identifiers within 63 bytes, types, CHECKs, triggers,db.migrate(), an index under every
statement),transactions.test.js(a caller'stspanning its own write and every store's, a
rollback leaving none, a refusal undoing only its savepoint), query-count checks (drafts()is one
query,setTerms()andattachMany()do not grow with their input, a feed of 12 pages costs the same
queries as a feed of 1), andpostgres.test.js: the stores on PostgreSQL 18 through PgBouncer
(transaction mode) with real concurrency (racing writers of one revision, a purge racing writes,
racing workers, racing get-or-create andsetTerms, callers' transactions), which runs when
OV_TEST_PG_URLis set and otherwise printspostgresql+pgbouncer: skipped (…). CI starts the
containers (eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test). npm test(Node 22.22.1): 16/16 test files with the containers, 121 cases (113 on PGlite or
pure, 8 through PgBouncer; 0.4 had 92 in 13 files); without the containers 15/16 files pass and
postgres.test.jsis listed as skipped.
0.4.0 — 2026-09-24
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.
0.3.2 — 2026-09-24
Depends on openvibe-shared v1.11.0 (the OpenVibe Frame rename). No API change.
0.3.1 — 2026-09-24
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.
0.3.0 — 2026-09-24
openvibe-publishing/ai: how a content product asks OpenVibe.AI for a draft.createAiClient({ baseUrl, tokenClient })runs a registered workflow (POST /api/v1/runs?wait=, polling a run still
going), reads its citations, and returns the output with the{ id, version, runId, model }an
AI authorship record needs. Failures throwAiRunErrorwith a code (ai.not_configured,
ai.refused,ai.run_failed,ai.timeout,ai.unreachable), never a partial draft; a refused
token is invalidated and retried once; a traceparent passed in goes along. Additive.
0.2.2 — 2026-09-24
openvibe-sharedv1.0.0 -> v1.5.1 (the runtime dependency;seois unchanged, so consumers that
pin a newer openvibe-shared share one copy once this is tagged). Tests useopenvibe-contracts
v0.33.0 (devDependency).
0.2.1 — 2026-09-23
Security fix (no API change).
Fixed
- ReDoS in
ssr.renderMarkdownandssr.markdownToText. Several patterns backtracked
super-linearly on untrusted Markdown, blocking the event loop on every render of the page:
a heading line with a long run of trailing spaces (# a+ 4,000 spaces +xtook 28 s, cubic),
unclosed**/__/~~openers (200 KB took 8 s each), backtick runs of rising length (26 s),
a fence line with trailing spaces before a non-word character (quadratic), and[runs in
markdownToText(quadratic). Headings, fences, emphasis and code spans are now matched in
linear (code spans O(n log n)) time with the same output (checked by differential fuzzing
against 0.2.0). One visible change:markdownToTextno longer treats a[inside link text as
part of it ([a [b](u) c→[a b c, wasa [b c). Products that render user Markdown with
this module should move to 0.2.1.
0.2.0 — 2026-09-22
Breaking: index-hooks now emits the released search.index-document@1 (openvibe-contracts
v0.12.0, owned by OpenVibe.Search) instead of the 0.1.0 proposal, and builds the events Search
actually consumes. Products on 0.1.0 must update their calls; nothing else changed.
Changed (breaking)
buildIndexDocument()takesowner(wasservice) and returns exactly the contract shape:
owner,type,id,revision,deleted,visibility,acl,canonical_url,title,
summary,body,facets,language(waslocale),authorship,provenance,
publication_state,published_at,updated_at,indexability { decision, reasons }.
Gone:schema,owner_service,resource_type,resource_id,acl.public,body_truncated,
theindexability.indexable/listable/gatefields and the provenance object.- Mappings onto the contract enums: visibility
gated→members; authorshiphybrid→
ai_assisted,ai→ai_generated; gate reason codes → Search's known reasons where one exists
(thin→thin_content,ai_generated_unreviewed→ai_unreviewed,unreviewed_sensitive→
sensitive_unreviewed,gated→members_only,unpublished→not_published,
missing_canonical→missing_canonical_url,noindex_requested→owner_decision; the rest
keep the gate's code).provenanceis now an array of typed references built from citations
(Sources items, or the owner's own citation records), the AI run (withstub: truefor stub
output) and extraprovenancerefs. tombstone()returns exactly{ owner, type, id, revision, deleted: true }. Anything not
published (draft, scheduled, unpublished, retracted, archived, deleted) is still a tombstone, and
so is unlisted content unlessincludeUnlisted: true(thenvisibility: 'unlisted').- ACL follows the contract:
subjectsareusr_/gst_only, plusgroupsandentitlements;
private needs subjects, members needs at least one of the three. Text is clipped to the contract's
limits (title 500, summary 4000, body 48000); facets are checked against its rules. publicationEvent()(the product's own<product>.<type>.<action>event) no longer embeds the
document; its payload is{ canonical_url, publication_state, indexability }.
Added
indexEvent({ document }):<owner>.index_document.upserted(payload = document) or
<owner>.index_document.deleted(payload ={ type, id, revision }), subject
{ type, id, revision }, visibility internal — the envelopes OpenVibe.Search's webhook consumes.createIndexSequencer(db, { prefix }): a monotonic index revision per resource in
<prefix>_index_revisions. Search refuses a different document at an equal revision and lets a
tombstone win ties, so every indexed change (content, visibility, state, canonical URL,
decision) needs a higher revision; an unchanged document keeps its revision (a safe replay).searchIndexability(decision),SEARCH_REASONS,AUTHORSHIP,LIMITS.- Tests validate every emitted document with
validate('search.index-document@1')and every
envelope withvalidate('events.event-envelope@1')from openvibe-contracts v0.12.0, and mirror
Search's webhook checks (owner = source, subject matches the payload).
Removed
docs/contracts-proposal/search.index-document.v1.json(superseded by the released contract).
0.1.0 — 2026-09-22
First version (roadmap Wave 15). Packages only: no runtime, no domain, no database.
Added
revisions: drafts, immutable revisions with parent pointers (UPDATE/DELETE blocked by SQLite
triggers), optimistic concurrency with a 412revision.conflict, line and word diffs (Myers, with
a bounded fallback), revert as a new revision, auditedpurgeEntity.schedule: idempotent publish/unpublish jobs with leases; re-run safe after a worker crash;
retry with backoff; abandoned aftermaxAttempts; injectable clock.taxonomy: slugs, get-or-create terms per vocabulary, hierarchical categories, entity links.citations: append-only source references per revision (Sources item id / URL, retrieved_at,
quote span, license note),carryForward, lookups by source item.media: attachments by Media object id withunverified | available | brokenstates and an
explicit broken placeholder infigureHtml.discussion: Community thread resolution (POST /api/v1/comments/threads/resolve) with a
service token; stores thread ids only.seo: the deterministic indexability gate (19 stable reason codes,hidden/noindexeffects),
canonical URL builder, history-aware redirects, meta/robots tags, sitemaps, RSS 2.0, Atom 1.0,
JSON Feed 1.1, JSON-LD builders (Article/BlogPosting/NewsArticle, Review, Product/Offer,
AggregateRating, BreadcrumbList, WebPage) that omit anything not provided.authorship: human / ai / hybrid / imported records with AI workflow + run references,
disclosure labels, append-only human review log; AI content starts as draft + noindex.index-hooks: Search index documents and tombstones,actionFortransitions, and
<product>.<type>.<action>event envelopes valid againstevents.event-envelope@1.ssr: escaping templates, safe Markdown subset, pagination, breadcrumbs, diff markup,<time>.examples/two-products: a mini wiki and a mini blog with separate databases (the exit proof).- Proposed contract
docs/contracts-proposal/search.index-document.v1.json.