openvibers/openvibe.publishing Commits

Publishing 1.0.0: the stores run on PostgreSQL through openvibe-sdk/db, async (ADR-035 and its amendment: PostgreSQL 18 behind PgBouncer is every service's system of record, one SQL dialect, PGlite in tests). Every store (revisions, citations, media, discussion refs, schedule, the review log, taxonomy, redirects, the index sequencer) takes the product's openvibe-sdk/db handle instead of a better-sqlite3 one, and every method that touches data is async with the 0.4 names and result shapes. Each data method takes the caller's transaction handle as an optional first argument, so a product's own write and the stores' writes commit or roll back together; multi-statement store writes run in their own db.tx or in a savepoint of the caller's, so a 412/404/410 leaves the caller's transaction usable; indexSequencer.stamp(t, doc) takes the handle always, like outbox.enqueue(t, …). Stores no longer create tables: each module exports schema(prefix) (seo redirectsSchema, index-hooks sequencerSchema, and the root schema({ … }) joining several) for the product's migrations, and store.ensureSchema() runs it for tests. The DDL uses bigint identity keys, jsonb, bigint epoch ms, timestamptz for citations' retrieved_at and COLLATE "C" on identifiers, keeps every CHECK, turns the RAISE(ABORT) guards into PL/pgSQL triggers (SQLSTATE 23001), and has an index under every statement the stores send. Writers of one entity take pg_advisory_xact_lock where 0.4 relied on SQLite's single writer; the scheduler claims with one UPDATE … FOR UPDATE SKIP LOCKED; get-or-create uses ON CONFLICT; loops became ANY/unnest queries and batched inserts (setTerms, attachMany, carryForward, drafts, verify); lists are bounded, cross-entity lists page by keyset (after), and new batch reads (revisions.getMany, citations.forRevisions, reviews.latestMany, taxonomy.termsForMany) keep list routes free of N+1. openvibe-sdk >=0.15.0 is a peer (one copy of the data layer per service; the sql fragments must come from the handle's own SDK), better-sqlite3 is dropped, and PGlite, pg and the SDK v0.15.0 tag are devDependencies. The two-product example applies generated migrations with db.migrate and writes its Search events in the same transaction. Every 0.4 test is kept on PGlite; new schema, transaction, query-count and PgBouncer integration tests; CI starts the PostgreSQL 18 + PgBouncer containers. npm test (Node 22.22.1): 16/16 files, 121 cases with the containers (8 through PgBouncer); 15/16 and 1 skipped without them.

OP OpenVibers committed 28 Sept 2026 · 184b663c78 · 39 files, +3466 −1744


Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex a655593..7afb0fc 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -8,19 +8,27 @@ jobs:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@66b76f868ea8228aad7811309eafef3726b036f3
with:
-- syntax-dirs: lib
-- # A consumer installs the package from a tag-style tarball and loads every module.
++ syntax-dirs: lib examples test
++ # The suite on PGlite, plus test/postgres.test.js against PostgreSQL 18 + PgBouncer (transaction mode)
++ # containers started by the openvibe-sdk devDependency's script (ADR-035).
++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test'
++ # A consumer installs the package from a tag-style tarball next to openvibe-sdk and loads every module.
extra: |
git archive --format=tar.gz --prefix=OpenVibe.Publishing-ci/ -o "$RUNNER_TEMP/openvibe-publishing.tgz" HEAD
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
-- npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-publishing.tgz" better-sqlite3@11 https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0
++ npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-publishing.tgz" https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0 @electric-sql/pglite@^0.5.8 https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0
node -e "
-- const p = require('openvibe-publishing');
-- for (const m of ['revisions','schedule','taxonomy','citations','media','discussion','seo','authorship','index-hooks','ssr']) require('openvibe-publishing/' + m);
-- const Database = require('better-sqlite3');
-- const db = new Database(':memory:');
-- p.revisions.createRevisionStore(db, { prefix: 'ci' }).create({ entityId: 'e', expectedRevision: 0, content: 'x' });
-- console.log('ok', require('openvibe-publishing/package.json').version);
++ (async () => {
++ const p = require('openvibe-publishing');
++ for (const m of ['revisions','schedule','taxonomy','citations','media','discussion','seo','authorship','index-hooks','ssr','ai']) require('openvibe-publishing/' + m);
++ const { createDb } = require('openvibe-sdk/db');
++ const db = createDb({ pglite: true });
++ const revs = await p.revisions.createRevisionStore(db, { prefix: 'ci' }).ensureSchema();
++ await revs.create({ entityId: 'e', expectedRevision: 0, content: 'x' });
++ if ((await revs.headNumber('e')) !== 1) throw new Error('no revision');
++ await db.close();
++ console.log('ok', require('openvibe-publishing/package.json').version);
++ })().catch((err) => { console.error(err); process.exit(1); });
"
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo.
security:
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3c5c2d2..afb8c7a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,145 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
++## 1.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: the `sql` fragments 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 the `sql` tag from the handle itself (`db.sql`), and `lib/` never loads a driver.
++- `better-sqlite3` is gone (it was a peer dependency and a devDependency). `@electric-sql/pglite`
++ (`^0.5.8`) and `pg` (`^8.23.0`) are devDependencies; products bring `pg` themselves.
++
++### Changed (every store)
++- **The handle** is an `openvibe-sdk/db` handle (`createDb`): `query`, `many`, `maybe`, `one`, `value`,
++ `exec`, `tx` and `sql`. A better-sqlite3 handle throws a TypeError that names 0.4.x.
++- **Every method that touches data is `async`** and returns a promise; argument errors that were
++ synchronous throws are now rejections (the prefix and handle checks of `create…()` 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 own `db.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 and `await store.ensureSchema()` runs it
++ (tests, PGlite) and resolves to the store. The root export adds `schema({ module: prefix | [prefixes] })`
++ joining several. `seo` exports it as `redirectsSchema(prefix)` and `index-hooks` as
++ `sequencerSchema(prefix)` (their `schema` names 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()` with `BEFORE 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.js` EXPLAINs 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_number` is 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`): new `exists(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 by `updated_at`
++ then owner. `diff()` reads both revisions in one query. `list()`'s `before` must be a number.
++ `saveDraft()` is one statement. A malformed revision number finds nothing (null) instead of erroring.
++- **citations** (`createCitationStore`): new `forRevisions(refs)` and `tables` (`{ citations, purges }`).
++ `attachMany()` validates every row before the first write and inserts them in one statement;
++ `carryForward()` is one `INSERT … 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). `retrievedAt` is read from `timestamptz` (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 one `UPDATE … RETURNING` each. `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 is `false`.
++- **discussion** (`createDiscussionRefs`): `get`, `set`, `forget`, `threadFor` are async; `ref` is
++ `jsonb`; `schema(prefix)` is exported. `threadFor()` calls Community between two statements, never
++ inside a transaction it opens. `createDiscussionClient` is 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's `BEGIN IMMEDIATE`); abandoned jobs
++ are marked in the same statement. `fail()` is one statement (backoff computed in SQL). `runDue()`
++ reads each finished job from its `RETURNING` row. `jobs(entityId, { limit = 1000 })`. `backoffMs` must
++ be a list of non-negative integers.
++- **authorship** (`createReviewLog`): `record`, `latest`, `history(entityId, { limit = 1000 })` are
++ async; new `latestMany(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 with `ON 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 })`. New `termsForMany(entityIds, vocabulary)`.
++- **seo** (`createRedirectStore`): `recordMove`, `release`, `resolve`, `history(entityId, { limit = 1000 })`
++ are async; `recordMove()` is one statement; `resolve()` awaits `currentPath(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's `enqueue(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.
++ Pass `db` only when nothing else is written. `stamp` is 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')`): new `schema(stores)`.
++
++### Examples and tests
++- `examples/two-products`: both apps take an `openvibe-sdk/db` handle, apply
++ `migrations/0001_initial.sql` (their own tables plus `schema()`, generated by `migrations.js`) with
++ `db.migrate()`, run related writes in one `db.tx` with `t` passed 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 and `await`. 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's `t` spanning 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()` and `attachMany()` do not grow with their input, a feed of 12 pages costs the same
++ queries as a feed of 1), and `postgres.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 and `setTerms`, callers' transactions), which runs when
++ `OV_TEST_PG_URL` is set and otherwise prints `postgresql+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.js` is 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.
ddiff --git a/README.md b/README.md
iindex bf343cd..0816384 100644
---- a/README.md
++++ b/README.md
@@@ -3,11 +3,14 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
--**Status:** alpha, 0.4.0 (roadmap Wave 15). The modules are tested and the exit proof
--(`examples/two-products`) runs in `npm test`. Releases v0.1.0 to v0.4.0 are tagged (see
--[CHANGELOG.md](CHANGELOG.md): the `ai` module in v0.3.0, openvibe-shared as a peer dependency in
--v0.4.0). Seven products pin v0.4.0: Wiki and Blog (public at openvibe.wiki and openvibe.blog) and News,
--Reviews, Deals, Coupons and Trade (deployed loopback-only on the host, not launched).
++**Status:** alpha, **1.0.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
++layer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method is
++async, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; each
++store gives its DDL as `schema(prefix)` for the product's migrations. The exit proof
++(`examples/two-products`) runs in `npm test`. Releases v0.1.0 to v1.0.0 are tagged (see
++[CHANGELOG.md](CHANGELOG.md)). The seven products (Wiki and Blog, public at openvibe.wiki and
++openvibe.blog; News, Reviews, Deals, Coupons and Trade, deployed loopback-only, not launched) still pin
++v0.4.x on SQLite; each moves to 1.x in its own PostgreSQL migration.
**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).
**License:** MIT, like OpenVibe.Shared.
@@@ -20,8 +23,8 @@ state and rules; this repository keeps the code they share.
## Owns
--- the modules below and the table layouts they create, with the product's prefix, inside the
-- product's own database
++- the modules below and their table layouts (the DDL each gives as `schema(prefix)`), with the
++ product's prefix, inside the product's own database
- the indexability gate's rules and its stable reason codes (`seo.REASONS`)
- the shape of the documents and envelopes the products send to OpenVibe.Search and OpenVibe.Events
@@@ -31,10 +34,11 @@ It owns no state: no runtime, port, domain or database of its own (next section)
This repository is **code, never state**. It has no runtime, no port, no domain, no database and
no API of its own, and it must never get one. Each product (Wiki, Blog, News, …) owns its
--publication state in its own database; these modules take the product's `better-sqlite3` handle
--and a table prefix, and create `<prefix>_…` tables *inside that product's database*. Two products
--share the code, not the data: `wiki.db` holds `wiki_*` tables, `blog.db` holds `blog_*` tables,
--and nothing is shared between them (proved by [test/two-products.test.js](test/two-products.test.js)).
++publication state in its own database; these modules take the product's `openvibe-sdk/db` handle
++and a table prefix, and use `<prefix>_…` tables *inside that product's database* (the product's
++migrations create them from `schema(prefix)`). Two products share the code, not the data: the wiki's
++database holds `wiki_*` tables, the blog's holds `blog_*` tables, and nothing is shared between them
++(proved by [test/two-products.test.js](test/two-products.test.js)).
Following the plan (§12.4, §31.1, §31.5): share *how* something is done (revisions, the gate,
feeds, indexing); never share *what is true* (which revision is live, who may read it, editorial
@@@ -58,21 +62,129 @@ Every module is its own entry point and can be used alone.
| `openvibe-publishing/ai` | Ask OpenVibe.AI for a draft: run a registered workflow with the product's service token (polls a slow run), read its citations, get the `{ id, version, runId, model }` an AI authorship record needs; coded `AiRunError`s, never a partial draft | — |
| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
--`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).
++`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …),
++plus `schema({ … })`, the DDL of several stores at once (below).
## Install
Pin the release tarball, like every OpenVibe package (never a `file:` link or a vendored copy):
```json
--"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0"
++"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
++"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
++"pg": "^8.23.0"
```
--`better-sqlite3` (≥ 11) and `openvibe-shared` (≥ 1.5.0) are peer dependencies, resolved from the
--product's own install (the product brings its own database handle, and one copy of openvibe-shared).
--openvibe-shared's `seo` module is the network's single implementation of head tags, sitemap XML,
--robots.txt and JSON-LD escaping; this package adds the publication rules on top. There are no other
--runtime dependencies.
++and `@electric-sql/pglite` (`^0.5.8`) as a devDependency for tests. `openvibe-sdk` (≥ 0.15.0) and
++`openvibe-shared` (≥ 1.5.0) are peer dependencies, resolved from the product's own install: the
++product brings its own database handle, so there is one copy of the data layer (and of its pool) per
++service, and one copy of openvibe-shared. openvibe-shared's `seo` module is the network's single
++implementation of head tags, sitemap XML, robots.txt and JSON-LD escaping; this package adds the
++publication rules on top. There are no other runtime dependencies; `lib/` never loads a driver.
++
++## Using the stores on PostgreSQL
++
++**The handle.** Every store takes the product's `openvibe-sdk/db` handle and a prefix. Creating a
++store runs no query; every method that touches data is `async`.
++
++```js
++const { createDb, sql } = require('openvibe-sdk/db');
++const { createRevisionStore } = require('openvibe-publishing/revisions');
++const { createCitationStore } = require('openvibe-publishing/citations');
++
++const db = createDb({ service: 'wiki' }); // DATABASE_URL, through PgBouncer
++const revisions = createRevisionStore(db, { prefix: 'wiki_page' });
++const citations = createCitationStore(db, { prefix: 'wiki', revisions });
++const { revision } = await revisions.create({ entityId: 'pg_1', expectedRevision: 0, content: '# Rye', fields: { title: 'Rye' } });
++```
++
++**The schema goes in the product's migrations.** The runtime role behind PgBouncer cannot create
++tables, so the stores never do it on their own. Each storing module exports its DDL for a prefix, and
++the root export joins several; write the text into the product's first migration once:
++
++```js
++const publishing = require('openvibe-publishing');
++const ddl = publishing.schema({ revisions: 'wiki_page', citations: 'wiki', seo: 'wiki_page', authorship: 'wiki_page', indexHooks: 'wiki' });
++// migrations/0001_initial.sql = '-- phase: expand
++' + the product's own tables + ddl
++// then at boot, as the owner: await createDb({ url: process.env.DATABASE_DIRECT_URL }).migrate({ dir })
++```
++
++| Module | DDL function | Tables |
++|---|---|---|
++| revisions | `schema(prefix)` | `<prefix>_revisions`, `<prefix>_drafts`, `<prefix>_revision_purges` + guard trigger |
++| citations | `schema(prefix)` | `<prefix>_citations`, `<prefix>_citation_purges` + guard trigger |
++| media | `schema(prefix)` | `<prefix>_attachments` |
++| discussion | `schema(prefix)` | `<prefix>_discussion_refs` |
++| schedule | `schema(prefix)` | `<prefix>_schedule_jobs` |
++| authorship | `schema(prefix)` (the review log) | `<prefix>_reviews` + guard trigger |
++| taxonomy | `schema(prefix)` | `<prefix>_terms`, `<prefix>_term_links` |
++| seo | `redirectsSchema(prefix)` | `<prefix>_redirects` |
++| index-hooks | `sequencerSchema(prefix)` | `<prefix>_index_revisions` |
++
++Every store also has `store.schema()` (the same text) and `await store.ensureSchema()`, which runs it
++on the handle: for tests and PGlite, where the handle may create tables. The text is idempotent
++(`IF NOT EXISTS`, `CREATE OR REPLACE`), uses PostgreSQL types (`bigint` identity keys, `jsonb` for
++JSON, `bigint` epoch milliseconds where 0.4 stored milliseconds, `timestamptz` for citations'
++`retrieved_at`, `COLLATE "C"` on identifier columns so they sort in byte order as SQLite did), keeps
++0.4's CHECK constraints, and has an index for every query a store sends (`test/schema.test.js`
++checks each one with EXPLAIN). When a later release changes a store's DDL, its CHANGELOG entry gives
++the migration a product adds; an applied migration is never edited.
++
++**Transaction handles.** Every data method takes an optional transaction handle as its **first**
++argument. Without one it runs on the store's `db`; with one it runs inside the caller's
++transaction, so a product's own write and the stores' writes commit together or not at all:
++
++```js
++await db.tx(async (t) => {
++ await t.exec(sql`INSERT INTO wiki_pages (id, slug, owner, updated_at) VALUES (${id}, ${slug}, ${owner}, ${Date.now()})`);
++ const { revision } = await revisions.create(t, { entityId: id, expectedRevision: 0, content, fields: { title } });
++ await citations.attachMany(t, id, revision.number, sources);
++});
++```
++
++Inside `db.tx`, pass `t` to every store call, reads included: a read on `db` would not see the
++transaction's writes (and on PGlite, one connection, it waits for the transaction). A store write
++that needs several statements runs them in its own transaction, or in a savepoint of the caller's
++(`t.tx`); a store refusal (a 412 conflict, a 404, a 410) is raised before or after a clean savepoint,
++so the caller can catch it and still commit the rest. `indexSequencer.stamp(t, document)` takes the
++handle **always**, like the SDK outbox's `enqueue(t, …)`: the stamped revision must commit if and
++only if the event carrying it does.
++
++**Concurrency.** Writers of one entity take a transaction-scoped advisory lock
++(`pg_advisory_xact_lock`, allowed behind PgBouncer), so revisions' and citations' check-then-write,
++purges and `setTerms` behave as they did under SQLite's single writer: racing writers of one base
++revision get exactly one success and 412s, and nothing written during a purge survives it. The
++scheduler claims with `FOR UPDATE SKIP LOCKED` in one statement, so any number of workers on any
++number of hosts share one jobs table without taking a job twice. Get-or-create paths use
++`ON CONFLICT`. `test/postgres.test.js` races all of these through PgBouncer.
++
++**Lists are bounded; several reads are one query.** Every list method has a `limit` (defaults keep
++0.4's answers for normal sizes), and lists that grow across entities page by keyset (`after`):
++`citations.bySourceItem`, `media.entitiesUsing`, `taxonomy.entitiesFor` and `taxonomy.terms`
++(revisions' `list` keeps its `before` cursor). For a feed or sitemap, read in batches instead of per
++item: `revisions.getMany(refs)`, `citations.forRevisions(refs)`, `reviews.latestMany(refs)` and
++`taxonomy.termsForMany(entityIds)` (refs are `{ entityId, revision }`; answers come back in the same
++order).
++
++**Tests.** `createDb({ pglite: true })`, then `await store.ensureSchema()` (or `db.migrate({ dir })`
++with the product's migrations): real PostgreSQL in-process.
++
++## Moving a product from 0.4 to 1.0
++
++Mostly mechanical; [CHANGELOG.md](CHANGELOG.md) lists every change. In short:
++
++1. Pin `openvibe-publishing` v1.0.0, `openvibe-sdk` v0.15.0 and `pg`; `@electric-sql/pglite` for tests.
++2. Put `publishing.schema({ … })` for the stores and prefixes the product uses into its
++ `migrations/0001_initial.sql` (with its own tables), so the SDK's `importSqlite` finds every table
++ when it moves the SQLite data.
++3. Pass the `openvibe-sdk/db` handle instead of the better-sqlite3 one, and `await` every store call.
++4. Replace `db.transaction(() => { … })()` around store calls with `await db.tx(async (t) => { … })`
++ and pass `t` as the first argument of every store call inside it.
++5. Beyond `await`: `stamp(t, doc)` takes the handle first; `redirects.resolve()`'s `currentPath`
++ may be async (and should be); rows come back with parsed JSON (`fields`, `meta`, `ref`, `result`)
++ exactly as the store shapes already returned them; list methods cap at their `limit`; loops of
++ per-item reads in list routes become the batch reads above.
## The indexability gate
@@@ -108,7 +220,8 @@ published document) all require the decision, so an object without one cannot re
- **Feeds and sitemaps** never invent `pubDate`, `lastmod` or authors; an Atom entry without any date
is left out rather than dated "now".
- **Citations** never get a retrieval time they did not have, and cannot be edited or deleted
-- (SQLite triggers) except by an audited purge.
++ (a PL/pgSQL trigger, SQLSTATE 23001) except by an audited purge. Revisions are guarded the same way;
++ review-log rows cannot be updated.
- **Media** outages are `check_failed`, not "gone" and not "fine".
- **Discussion** failures are errors, never a fabricated thread.
- **AI content** starts as draft + noindex: `authorship.canPublish()` refuses it and the gate hides it
@@@ -132,8 +245,10 @@ tombstone win ties, so the revision must rise with every indexed change, not onl
```js
const seq = hooks.createIndexSequencer(db, { prefix: 'wiki' });
--const doc = seq.stamp(hooks.buildIndexDocument({ owner: 'wiki', type: 'page', id, revision: 0, state, visibility, … , decision }));
--outbox.enqueue(hooks.indexEvent({ document: doc })); // wiki.index_document.upserted | .deleted
++await db.tx(async (t) => {
++ const doc = await seq.stamp(t, hooks.buildIndexDocument({ owner: 'wiki', type: 'page', id, revision: 0, state, visibility, … , decision }));
++ await outbox.enqueue(t, hooks.indexEvent({ document: doc })); // wiki.index_document.upserted | .deleted
++});
```
`indexEvent()` builds the envelope Search's webhook consumes (payload = the document, or
@@@ -147,13 +262,17 @@ v0.49.0 and mirror Search's webhook checks.
## Exit proof: two products
[examples/two-products](examples/two-products) holds a mini wiki and a mini blog, each with its own
--SQLite file and its own publication tables, both using revisions, citations, the gate and feeds with
--different editorial policies. `node examples/two-products/wiki/app.js` (port 4801) and
--`node examples/two-products/blog/app.js` (port 4811) serve them on temp databases.
--[test/two-products.test.js](test/two-products.test.js) shows they load the same modules, that each
--database holds only its own product's tables, that writes never cross, and that pages are useful
--without JavaScript, old slugs 301, private/VIP/deleted content leaves feeds and sitemaps, and
--scheduled publication survives a worker restart.
++database (an `openvibe-sdk/db` handle) and its own publication tables, both using revisions,
++citations, the gate and feeds with different editorial policies. Each applies its
++`migrations/0001_initial.sql` (its own tables plus `publishing.schema({ … })`, generated by
++`examples/two-products/migrations.js`) with `db.migrate()`, as a service does.
++`node examples/two-products/wiki/app.js` (port 4801) and `node examples/two-products/blog/app.js`
++(port 4811) serve them on in-memory PGlite. [test/two-products.test.js](test/two-products.test.js)
++shows they load the same modules, that each database holds only its own product's tables, that
++writes never cross, that a product's write and the stores' writes share one transaction, that a feed
++of many pages costs the same queries as a feed of one, and that pages are useful without JavaScript,
++old slugs 301, private/VIP/deleted content leaves feeds and sitemaps, and scheduled publication
++survives a worker restart.
## Capabilities
@@@ -164,23 +283,33 @@ The package implements no capability and holds no grant. Three modules call a se
## Acceptance
--`npm test` runs every `test/*.test.js` on temp databases with no network. What it proves: immutable
--revisions, diff and revert (`revisions.test.js`); idempotent scheduling across worker restarts
--(`schedule.test.js`); append-only citations (`citations.test.js`); honest media states
--(`media.test.js`); discussion references without content (`discussion.test.js`); the gate, structured
--data and feeds built only from given fields (`seo.test.js`); AI content held until a person's review
--(`authorship.test.js`, `ai.test.js`); Search documents and envelopes valid against openvibe-contracts
--(`index-hooks.test.js`); escaping and the Markdown subset, including the ReDoS fix (`ssr.test.js`);
--every subpath export loading alone (`package.test.js`); and the two-product exit proof
--(`two-products.test.js`).
++`npm test` runs every `test/*.test.js` on real PostgreSQL in-process (PGlite, one fresh schema per
++test) with no network. What it proves: immutable revisions, diff and revert, keyset lists, drafts in
++one query (`revisions.test.js`); idempotent scheduling across worker restarts, concurrent claims
++(`schedule.test.js`); append-only citations, batched inserts (`citations.test.js`); honest media
++states (`media.test.js`); discussion references without content (`discussion.test.js`); the gate,
++redirects, structured data and feeds built only from given fields (`seo.test.js`); AI content held
++until a person's review (`authorship.test.js`, `ai.test.js`); Search documents and envelopes valid
++against openvibe-contracts, and `stamp(t, …)` committing with its event (`index-hooks.test.js`);
++escaping and the Markdown subset, including the ReDoS fix (`ssr.test.js`); every subpath export loading
++alone without a driver (`package.test.js`); the DDL: idempotent, 63-byte names, PostgreSQL types,
++CHECKs, PL/pgSQL guards, applied by `db.migrate()`, and an index under every query the stores send
++(`schema.test.js`); callers' transactions spanning store writes, rollbacks leaving nothing, savepoints
++(`transactions.test.js`); and the two-product exit proof (`two-products.test.js`).
++`test/postgres.test.js` runs the stores on PostgreSQL 18 through PgBouncer (transaction mode) when
++`OV_TEST_PG_URL` is set: racing writers, a purge racing writes, racing workers, racing get-or-create and
++`setTerms`, and callers' transactions behind the pooler; otherwise it prints
++`postgresql+pgbouncer: skipped (…)`. CI starts the containers, so it always runs there.
## Security
Reporting a vulnerability: [SECURITY.md](SECURITY.md). The `ssr` module auto-escapes every value in
its `html` templates and renders only a safe Markdown subset (linear-time since v0.2.1's ReDoS fix);
JSON-LD is escaped by openvibe-shared. The package holds no secrets, opens no database it was not
--handed, and makes network calls only through the token client and base URL a product passes in. A
--product that bypasses its own checks is not stopped by the package (see Honesty guarantees).
++handed, and makes network calls only through the token client and base URL a product passes in. Every
++value reaches PostgreSQL as a bind parameter (the `sql` tag); the only identifiers in statements are
++the validated prefix's table names. A product that bypasses its own checks is not stopped by the
++package (see Honesty guarantees).
## Deploy
@@@ -192,7 +321,9 @@ Nothing is deployed from this repository. A release is a git tag (`vX.Y.Z`, reco
```bash
fnm exec --using=22.22.1 npm install
--fnm exec --using=22.22.1 npm test # every test/*.test.js, temp databases, no network
++fnm exec --using=22.22.1 npm test # every test/*.test.js on PGlite, no network
++# with PostgreSQL 18 + PgBouncer containers (what CI runs):
++eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && fnm exec --using=22.22.1 npm test
```
## Does not own
@@@ -205,8 +336,11 @@ fnm exec --using=22.22.1 npm test # every test/*.test.js, temp database
## Depends on
++- `openvibe-sdk` ≥ 0.15.0 (`openvibe-sdk/db`), a peer dependency supplied by the consumer: its
++ `createDb` handle (and `pg` in production, `@electric-sql/pglite` in tests) reach the stores through
++ the product. Tests pin v0.15.0.
++- PostgreSQL 18 (through PgBouncer in transaction mode), the product's own database (ADR-035).
- `openvibe-shared` ≥ 1.5.0 (`seo`), a peer dependency supplied by the consumer (tests use v1.25.0).
--- `better-sqlite3` ≥ 11, supplied by the consumer.
- Contracts it produces for: `common.entity-ref@1`, `media.media-ref@1`, `events.event-envelope@1`
and `search.index-document@1` (validated in tests against `openvibe-contracts` v0.49.0, a
devDependency: nothing in `lib/` needs it at runtime).
ddiff --git a/STATUS.json b/STATUS.json
iindex 55b3b31..ab70052 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,29 +5,48 @@
"wave": "W15",
"kind": "packages",
"package": "openvibe-publishing",
-- "version": "0.4.0",
++ "version": "1.0.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"port": null,
"domain": null,
-- "database": null,
-- "modules": ["revisions", "schedule", "taxonomy", "citations", "media", "discussion", "seo", "authorship", "index-hooks", "ai", "ssr"],
++ "database": "none of its own: the stores use tables in each product's PostgreSQL database through the product's openvibe-sdk/db handle (ADR-035); DDL from schema(prefix) in the product's migrations",
++ "modules": [
++ "revisions",
++ "schedule",
++ "taxonomy",
++ "citations",
++ "media",
++ "discussion",
++ "seo",
++ "authorship",
++ "index-hooks",
++ "ai",
++ "ssr"
++ ],
"consumers": [
-- "OpenVibe.Wiki (v0.4.0; public at https://openvibe.wiki)",
-- "OpenVibe.Blog (v0.4.0; public at https://openvibe.blog)",
-- "OpenVibe.News (v0.4.0; deployed loopback-only, not launched)",
-- "OpenVibe.Reviews (v0.4.0; deployed loopback-only, not launched)",
-- "OpenVibe.Deals (v0.4.0; deployed loopback-only, not launched)",
-- "OpenVibe.Coupons (v0.4.0; deployed loopback-only, not launched)",
-- "OpenVibe.Trade (v0.4.0; deployed loopback-only, not launched)"
++ "OpenVibe.Wiki (v0.4.0 on SQLite until it moves to 1.x; public at https://openvibe.wiki)",
++ "OpenVibe.Blog (v0.4.0 on SQLite until it moves to 1.x; public at https://openvibe.blog)",
++ "OpenVibe.News (v0.4.0 on SQLite until it moves to 1.x; deployed loopback-only, not launched)",
++ "OpenVibe.Reviews (v0.4.0 on SQLite until it moves to 1.x; deployed loopback-only, not launched)",
++ "OpenVibe.Deals (v0.4.0 on SQLite until it moves to 1.x; deployed loopback-only, not launched)",
++ "OpenVibe.Coupons (v0.4.0 on SQLite until it moves to 1.x; deployed loopback-only, not launched)",
++ "OpenVibe.Trade (v0.4.0 on SQLite until it moves to 1.x; deployed loopback-only, not launched)"
],
"plannedConsumers": [],
"exitProof": "examples/two-products + test/two-products.test.js",
"contracts": "openvibe-contracts v0.49.0 (devDependency): emits search.index-document@1 and events.event-envelope@1 (validated in tests)",
++ "dependsOn": [
++ "openvibe-sdk >=0.15.0 (peer; tests pin v0.15.0)",
++ "openvibe-shared >=1.5.0 (optional peer; tests pin v1.25.0)",
++ "PostgreSQL 18 through PgBouncer (the product's database)"
++ ],
++ "tests": "npm test (Node 22): 16 files, 121 cases; test/postgres.test.js runs on PostgreSQL 18 + PgBouncer when OV_TEST_PG_URL is set (CI starts the containers), else it is listed as skipped",
"released": true,
"deployed": false,
"deployment": "none: a package released as git tags; products pin the tag and deploy it with their own releases",
"features": [
++ "async stores on PostgreSQL through openvibe-sdk/db, with the caller's transaction handle, schema(prefix) DDL for migrations, indexes for every query and bounded, keyset-paged lists",
"revisions with optimistic concurrency, diff and revert",
"idempotent lease-based scheduling",
"taxonomy with hierarchical categories",
ddiff --git a/examples/two-products/README.md b/examples/two-products/README.md
iindex e9dbe3f..cb74203 100644
---- a/examples/two-products/README.md
++++ b/examples/two-products/README.md
@@@ -1,22 +1,30 @@
# Two products, one package, two databases
The Wave 15 exit proof. `wiki/app.js` and `blog/app.js` are deliberately small products that
--import `openvibe-publishing/*` exactly as a real product would, each opening its **own** SQLite file:
++import `openvibe-publishing/*` exactly as a real product would, each with its **own** database (an
++`openvibe-sdk/db` handle: PostgreSQL in production, PGlite here):
| | Mini wiki | Mini blog |
|---|---|---|
--| Database | `wiki.db` — only `wiki_*` tables | `blog.db` — only `blog_*` tables |
++| Database | only `wiki_*` tables | only `blog_*` tables |
++| Schema | `wiki/migrations/0001_initial.sql` | `blog/migrations/0001_initial.sql` |
| Publication state | `wiki_pages` (owned by the app) | `blog_posts` (owned by the app) |
| Revisions | `wiki_page_revisions` | `blog_post_revisions` |
| Citations | `wiki_citations` (sources required) | `blog_post_citations` (optional) |
| Gate policy | `minWords 30`, `requireSources` | `minWords 20` |
| Feeds | Atom + JSON Feed | RSS + JSON Feed |
--| Also | redirects, AI authorship + review, index events | taxonomy, scheduling, media attachments |
++| Also | redirects, AI authorship + review, index events in an outbox (same transaction) | taxonomy, scheduling, media attachments |
++
++Each migration is the product's own tables plus `require('openvibe-publishing').schema({ … })` for the
++stores it uses, written by `node examples/two-products/migrations.js --write` and applied with
++`db.migrate()`, the way a service does it. Writes that belong together run in one `db.tx`, with the
++transaction handle passed to every store call; feeds and sitemaps read in batches
++(`revisions.getMany`, `citations.forRevisions`, `reviews.latestMany`, `taxonomy.termsForMany`).
```bash
node examples/two-products/wiki/app.js # http://127.0.0.1:4801/p/rye
node examples/two-products/blog/app.js # http://127.0.0.1:4811/posts/first-loaf
```
--Both use temp databases and made-up sample text. The assertions live in
++Both use in-memory PGlite and made-up sample text. The assertions live in
[../../test/two-products.test.js](../../test/two-products.test.js).
ddiff --git a/examples/two-products/blog/app.js b/examples/two-products/blog/app.js
iindex 14e70fc..d28eaf4 100644
---- a/examples/two-products/blog/app.js
++++ b/examples/two-products/blog/app.js
@@@ -1,10 +1,12 @@
'use strict';
/**
-- * Mini blog — the second example consumer of openvibe-publishing, with its own SQLite file and its
++ * Mini blog — the second example consumer of openvibe-publishing, with its own database and its
* own publication state (blog_posts). Different editorial rules from the wiki (sources optional,
* shorter minimum), same shared mechanics.
*
-- * node examples/two-products/blog/app.js # http://127.0.0.1:4811 with a temp database
++ * node examples/two-products/blog/app.js # http://127.0.0.1:4811 with an in-memory PGlite
++ *
++ * The schema is migrations/0001_initial.sql (generated by ../migrations.js), applied with db.migrate().
*
* Uses: revisions (blog_post_*), citations (blog_post_citations), taxonomy (blog_terms), schedule
* (blog_schedule_jobs), media attachments (blog_post_attachments), seo gate + sitemap + RSS/JSON
@@@ -12,9 +14,6 @@
*/
const http = require('http');
const path = require('path');
--const os = require('os');
--const fs = require('fs');
--const Database = require('better-sqlite3');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');
@@@ -25,21 +24,10 @@ const ssr = require('openvibe-publishing/ssr');
const POLICY = { minWords: 20, requireSources: false };
--function createBlog({ dbPath, origin = 'https://openvibe.blog', mediaOrigin = 'https://openvibe.media', now = () => Date.now() } = {}) {
-- const db = new Database(dbPath);
-- db.pragma('journal_mode = WAL');
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS blog_posts (
-- id TEXT PRIMARY KEY,
-- slug TEXT NOT NULL UNIQUE,
-- author_name TEXT,
-- visibility TEXT NOT NULL DEFAULT 'public',
-- state TEXT NOT NULL DEFAULT 'draft',
-- published_revision INTEGER,
-- published_at INTEGER,
-- updated_at INTEGER NOT NULL
-- );
-- `);
++/** db: an openvibe-sdk/db handle the product owns. */
++async function createBlog({ db, origin = 'https://openvibe.blog', mediaOrigin = 'https://openvibe.media', now = () => Date.now() } = {}) {
++ const { sql } = db;
++ await db.migrate({ dir: path.join(__dirname, 'migrations'), log: { log() {} } });
const revisions = createRevisionStore(db, { prefix: 'blog_post', now });
const citations = createCitationStore(db, { prefix: 'blog_post', now, revisions });
const taxonomy = createTaxonomy(db, { prefix: 'blog', now });
@@@ -47,40 +35,39 @@ function createBlog({ dbPath, origin = 'https://openvibe.blog', mediaOrigin = 'h
const media = createAttachmentStore(db, { prefix: 'blog_post', now });
const urlOf = (slug) => seo.canonicalUrl(origin, ['posts', slug]);
-- const getPost = (id) => db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(id);
-- function decide(post, rev) {
++ function decideWith(post, rev, cites) {
return seo.evaluate({
state: post.state, visibility: post.visibility, canonicalUrl: urlOf(post.slug),
-- text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(post.id, rev.number).length,
++ text: ssr.markdownToText(rev.content), citationCount: cites.length,
}, { policy: POLICY, now: now() });
}
/** The idempotent effect a scheduled job applies: "revision N is the published one". */
-- function applyPublish(id, revision) {
-- const info = db.prepare("UPDATE blog_posts SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ? AND (state IS NOT 'published' OR published_revision IS NOT ?)")
-- .run(revision, now(), now(), id, revision);
-- return { changed: info.changes };
++ async function applyPublish(id, revision) {
++ const changed = await db.exec(sql`UPDATE blog_posts SET state = 'published', published_revision = ${revision}, published_at = COALESCE(published_at, ${now()}), updated_at = ${now()}
++ WHERE id = ${id} AND (state IS DISTINCT FROM 'published' OR published_revision IS DISTINCT FROM ${revision}::integer)`);
++ return { changed };
}
const api = {
db, revisions, citations, taxonomy, scheduler, media, policy: POLICY,
draft({ title, body, authorName = null, tags = [], sources = [] }) {
-- return db.transaction(() => {
++ return db.tx(async (t) => {
const slug = slugify(title);
const id = `post_${slug}`;
-- db.prepare('INSERT INTO blog_posts (id, slug, author_name, updated_at) VALUES (?, ?, ?, ?)').run(id, slug, authorName, now());
-- const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title } });
-- citations.attachMany(id, revision.number, sources);
-- taxonomy.setTerms(id, 'tag', tags);
++ await t.exec(sql`INSERT INTO blog_posts (id, slug, author_name, updated_at) VALUES (${id}, ${slug}, ${authorName}, ${now()})`);
++ const { revision } = await revisions.create(t, { entityId: id, expectedRevision: 0, content: body, fields: { title } });
++ await citations.attachMany(t, id, revision.number, sources);
++ await taxonomy.setTerms(t, id, 'tag', tags);
return { id, revision: revision.number };
-- })();
++ });
},
-- edit(id, { expectedRevision, body, title }) {
-- const head = revisions.head(id);
-- return revisions.create({ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title } }).revision.number;
++ async edit(id, { expectedRevision, body, title }) {
++ const head = await revisions.head(id);
++ return (await revisions.create({ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title } })).revision.number;
},
publishNow(id, revision) { return applyPublish(id, revision); },
@@@ -88,33 +75,36 @@ function createBlog({ dbPath, origin = 'https://openvibe.blog', mediaOrigin = 'h
runScheduled(worker) {
return scheduler.runDue({ worker, handler: (job) => (job.action === 'publish' ? applyPublish(job.entityId, job.revision) : null) });
},
-- setVisibility(id, visibility) { db.prepare('UPDATE blog_posts SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id); },
--
-- published() {
-- return db.prepare("SELECT * FROM blog_posts WHERE state = 'published' ORDER BY published_at DESC, id").all().map((post) => {
-- const rev = revisions.get(post.id, post.published_revision);
-- return { post, rev, decision: decide(post, rev) };
-- });
++ async setVisibility(id, visibility) { await db.exec(sql`UPDATE blog_posts SET visibility = ${visibility}, updated_at = ${now()} WHERE id = ${id}`); },
++
++ /** Published posts with their live revision, citations and gate decision: three queries however many posts. */
++ async published() {
++ const posts = await db.many(sql`SELECT * FROM blog_posts WHERE state = 'published' ORDER BY published_at DESC, id LIMIT 1000`);
++ const refs = posts.map((p) => ({ entityId: p.id, revision: p.published_revision }));
++ const [revs, cites] = await Promise.all([revisions.getMany(refs), citations.forRevisions(refs)]);
++ return posts.map((post, i) => ({ post, rev: revs[i], decision: decideWith(post, revs[i], cites[i]) }));
},
-- feedItems() {
-- return api.published().map(({ post, rev, decision }) => ({
++ async feedItems() {
++ const list = await api.published();
++ const tags = await taxonomy.termsForMany(list.map(({ post }) => post.id), 'tag');
++ return list.map(({ post, rev, decision }, i) => ({
id: `tag:openvibe.blog,2026:post/${post.id}`, url: urlOf(post.slug), title: rev.fields.title,
summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content),
published: post.published_at, updated: rev.createdAt, decision,
authors: post.author_name ? [{ name: post.author_name }] : [],
-- tags: taxonomy.termsFor(post.id, 'tag').map((t) => t.name),
++ tags: tags[i].map((t) => t.name),
}));
},
-- rss() { return seo.rssFeed({ title: 'Mini blog', link: `${origin}/`, description: 'Posts from the mini blog example', feedUrl: `${origin}/feed.xml` }, api.feedItems()); },
-- jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini blog', link: `${origin}/`, feedUrl: `${origin}/feed.json` }, api.feedItems()); },
-- sitemapXml() { return seo.sitemap(api.published().map(({ post, rev, decision }) => ({ loc: urlOf(post.slug), lastmod: rev.createdAt, decision }))).files[0]; },
--
-- renderPost(post) {
-- const rev = revisions.get(post.id, post.published_revision);
-- const decision = decide(post, rev);
-- const figures = media.list(post.id).map((a) => ssr.raw(figureHtml(a, { urlFor: (id) => `${mediaOrigin}/o/${id}` })));
-- const tags = taxonomy.termsFor(post.id, 'tag');
++ async rss() { return seo.rssFeed({ title: 'Mini blog', link: `${origin}/`, description: 'Posts from the mini blog example', feedUrl: `${origin}/feed.xml` }, await api.feedItems()); },
++ async jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini blog', link: `${origin}/`, feedUrl: `${origin}/feed.json` }, await api.feedItems()); },
++ async sitemapXml() { return seo.sitemap((await api.published()).map(({ post, rev, decision }) => ({ loc: urlOf(post.slug), lastmod: rev.createdAt, decision }))).files[0]; },
++
++ async renderPost(post) {
++ const rev = await revisions.get(post.id, post.published_revision);
++ const decision = decideWith(post, rev, await citations.forRevision(post.id, rev.number));
++ const figures = (await media.list(post.id)).map((a) => ssr.raw(figureHtml(a, { urlFor: (id) => `${mediaOrigin}/o/${id}` })));
++ const tags = await taxonomy.termsFor(post.id, 'tag');
const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',
jsonLd: seo.structuredData.article({ type: 'BlogPosting', headline: rev.fields.title, url: urlOf(post.slug),
datePublished: post.published_at ? new Date(post.published_at).toISOString() : null, dateModified: rev.createdAt,
@@@ -127,18 +117,23 @@ ${ssr.raw(ssr.renderMarkdown(rev.content))}
},
handler(req, res) {
-- const url = new URL(req.url, origin);
const send = (status, type, body) => { res.writeHead(status, { 'Content-Type': type }); res.end(body); };
-- if (url.pathname === '/feed.xml') return send(200, 'application/rss+xml', api.rss());
-- if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));
-- if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());
++ api.route(req, send).catch((err) => { if (!res.headersSent) send(500, 'text/plain', 'Server error'); console.error(err); });
++ },
++
++ async route(req, send) {
++ const url = new URL(req.url, origin);
++ if (url.pathname === '/feed.xml') return send(200, 'application/rss+xml', await api.rss());
++ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(await api.jsonFeedDoc()));
++ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', await api.sitemapXml());
const m = url.pathname.match(/^\/posts\/([^/]+)$/);
-- const post = m && db.prepare('SELECT * FROM blog_posts WHERE slug = ?').get(decodeURIComponent(m[1]));
++ const post = m && await db.maybe(sql`SELECT * FROM blog_posts WHERE slug = ${decodeURIComponent(m[1])}`);
if (!post || post.state !== 'published' || post.visibility === 'private' || post.visibility === 'gated') return send(404, 'text/plain', 'Not found');
-- return send(200, 'text/html; charset=utf-8', api.renderPost(post));
++ return send(200, 'text/html; charset=utf-8', await api.renderPost(post));
},
-- close() { db.close(); },
++ /** The database belongs to whoever opened it; the app holds nothing else. */
++ close() {},
};
return api;
}
@@@ -146,10 +141,13 @@ ${ssr.raw(ssr.renderMarkdown(rev.content))}
module.exports = { createBlog };
if (require.main === module) {
-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-blog-'));
-- const blog = createBlog({ dbPath: path.join(dir, 'blog.db'), origin: 'http://127.0.0.1:4811' });
-- const { id, revision } = blog.draft({ title: 'First loaf', authorName: 'Alex', tags: ['bread', 'rye'],
-- body: 'My first rye loaf came out dense but tasty. Next time I will give the starter a longer rise and bake it a little hotter.' });
-- blog.publishNow(id, revision);
-- http.createServer(blog.handler).listen(4811, '127.0.0.1', () => console.log(`mini blog on http://127.0.0.1:4811/posts/first-loaf (db ${dir}/blog.db)`));
++ (async () => {
++ const { createDb } = require('openvibe-sdk/db');
++ const db = createDb({ pglite: true, service: 'mini-blog' });
++ const blog = await createBlog({ db, origin: 'http://127.0.0.1:4811' });
++ const { id, revision } = await blog.draft({ title: 'First loaf', authorName: 'Alex', tags: ['bread', 'rye'],
++ body: 'My first rye loaf came out dense but tasty. Next time I will give the starter a longer rise and bake it a little hotter.' });
++ await blog.publishNow(id, revision);
++ http.createServer(blog.handler).listen(4811, '127.0.0.1', () => console.log('mini blog on http://127.0.0.1:4811/posts/first-loaf (in-memory PGlite)'));
++ })().catch((err) => { console.error(err); process.exit(1); });
}
ddiff --git a/examples/two-products/blog/migrations/0001_initial.sql b/examples/two-products/blog/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..e7d4483
---- /dev/null
++++ b/examples/two-products/blog/migrations/0001_initial.sql
@@@ -0,0 +1,167 @@
++-- phase: expand
++-- Mini blog: its own table, then the openvibe-publishing stores it uses.
++CREATE TABLE blog_posts (
++ id text PRIMARY KEY,
++ slug text NOT NULL UNIQUE,
++ author_name text,
++ visibility text NOT NULL DEFAULT 'public',
++ state text NOT NULL DEFAULT 'draft',
++ published_revision integer,
++ published_at bigint,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX blog_posts_published ON blog_posts (published_at DESC, id) WHERE state = 'published';
++-- openvibe-publishing/revisions (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_revisions (
++ id text PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ number integer NOT NULL CHECK (number >= 1),
++ parent_id text,
++ parent_number integer,
++ kind text NOT NULL CHECK (kind IN ('edit','revert','import')),
++ reverted_to integer,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ content_hash text NOT NULL,
++ author text,
++ message text,
++ created_at bigint NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS blog_post_revisions_entity_num ON blog_post_revisions (entity_id, number);
++CREATE TABLE IF NOT EXISTS blog_post_drafts (
++ entity_id text COLLATE "C" NOT NULL,
++ owner text COLLATE "C" NOT NULL,
++ base_revision integer NOT NULL,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (entity_id, owner)
++);
++CREATE INDEX IF NOT EXISTS blog_post_drafts_updated ON blog_post_drafts (entity_id, updated_at DESC, owner);
++CREATE TABLE IF NOT EXISTS blog_post_revision_purges (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++CREATE OR REPLACE FUNCTION blog_post_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'blog_post_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM blog_post_revision_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'blog_post_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER blog_post_revisions_no_update BEFORE UPDATE ON blog_post_revisions FOR EACH ROW EXECUTE FUNCTION blog_post_revisions_guard();
++CREATE OR REPLACE TRIGGER blog_post_revisions_no_delete BEFORE DELETE ON blog_post_revisions FOR EACH ROW EXECUTE FUNCTION blog_post_revisions_guard();
++
++-- openvibe-publishing/citations (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_citations (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL CHECK (revision >= 1),
++ anchor text,
++ source_item_id text COLLATE "C",
++ url text,
++ title text,
++ retrieved_at timestamptz,
++ quote_text text,
++ quote_start integer,
++ quote_end integer,
++ license_note text,
++ carried_from bigint REFERENCES blog_post_citations(id),
++ attached_by text,
++ attached_at bigint NOT NULL,
++ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
++);
++CREATE INDEX IF NOT EXISTS blog_post_citations_rev ON blog_post_citations (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS blog_post_citations_source ON blog_post_citations (source_item_id, entity_id, revision, id) WHERE source_item_id IS NOT NULL;
++CREATE INDEX IF NOT EXISTS blog_post_citations_carried ON blog_post_citations (carried_from) WHERE carried_from IS NOT NULL;
++CREATE TABLE IF NOT EXISTS blog_post_citation_purges (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++CREATE OR REPLACE FUNCTION blog_post_citations_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'blog_post_citations rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM blog_post_citation_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'blog_post_citations rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER blog_post_citations_no_update BEFORE UPDATE ON blog_post_citations FOR EACH ROW EXECUTE FUNCTION blog_post_citations_guard();
++CREATE OR REPLACE TRIGGER blog_post_citations_no_delete BEFORE DELETE ON blog_post_citations FOR EACH ROW EXECUTE FUNCTION blog_post_citations_guard();
++
++-- openvibe-publishing/taxonomy (prefix blog)
++CREATE TABLE IF NOT EXISTS blog_terms (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ vocabulary text NOT NULL,
++ slug text NOT NULL,
++ name text NOT NULL,
++ parent_id bigint REFERENCES blog_terms(id),
++ description text,
++ created_at bigint NOT NULL,
++ UNIQUE (vocabulary, slug)
++);
++CREATE INDEX IF NOT EXISTS blog_terms_parent ON blog_terms (parent_id, name);
++CREATE INDEX IF NOT EXISTS blog_terms_vocab ON blog_terms (vocabulary, name);
++CREATE TABLE IF NOT EXISTS blog_term_links (
++ entity_id text COLLATE "C" NOT NULL,
++ term_id bigint NOT NULL REFERENCES blog_terms(id),
++ position integer NOT NULL DEFAULT 0,
++ created_at bigint NOT NULL,
++ PRIMARY KEY (entity_id, term_id)
++);
++CREATE INDEX IF NOT EXISTS blog_term_links_term ON blog_term_links (term_id, entity_id);
++
++-- openvibe-publishing/schedule (prefix blog)
++CREATE TABLE IF NOT EXISTS blog_schedule_jobs (
++ id text COLLATE "C" PRIMARY KEY,
++ idem_key text NOT NULL UNIQUE,
++ entity_id text COLLATE "C" NOT NULL,
++ action text NOT NULL CHECK (action IN ('publish','unpublish')),
++ revision integer,
++ run_at bigint NOT NULL,
++ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),
++ attempts integer NOT NULL DEFAULT 0,
++ lease_owner text,
++ lease_until bigint,
++ last_error text,
++ result jsonb,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS blog_schedule_jobs_due ON blog_schedule_jobs (run_at, id) WHERE status = 'pending';
++CREATE INDEX IF NOT EXISTS blog_schedule_jobs_lease ON blog_schedule_jobs (lease_until) WHERE status = 'running';
++CREATE INDEX IF NOT EXISTS blog_schedule_jobs_entity ON blog_schedule_jobs (entity_id, run_at, id);
++
++-- openvibe-publishing/media (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_attachments (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer,
++ media_id text COLLATE "C" NOT NULL,
++ role text NOT NULL DEFAULT 'inline',
++ variant text,
++ alt text,
++ caption text,
++ position integer NOT NULL DEFAULT 0,
++ state text NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),
++ broken_reason text CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),
++ checked_at bigint,
++ created_at bigint NOT NULL,
++ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS blog_post_attachments_entity ON blog_post_attachments (entity_id, position, id);
++CREATE INDEX IF NOT EXISTS blog_post_attachments_media ON blog_post_attachments (media_id, entity_id);
ddiff --git a/examples/two-products/migrations.js b/examples/two-products/migrations.js
nnew file mode 100644
iindex 0000000..833f24b
---- /dev/null
++++ b/examples/two-products/migrations.js
@@@ -0,0 +1,68 @@
++'use strict';
++/**
++ * The two example products' first migrations, built the way a service builds its own: the product's
++ * tables, then openvibe-publishing's schema() for the stores it uses. A service writes this text into
++ * migrations/0001_initial.sql once and never edits it after it runs (a later library schema change
++ * arrives as the service's next migration).
++ *
++ * node examples/two-products/migrations.js --write # regenerate wiki/ and blog/ migrations
++ *
++ * test/two-products.test.js checks the committed files still match.
++ */
++const fs = require('fs');
++const path = require('path');
++const publishing = require('openvibe-publishing');
++
++const HEADER = '-- phase: expand\n';
++
++const FILES = {
++ 'wiki/migrations/0001_initial.sql': () => `${HEADER}-- Mini wiki: its own tables, then the openvibe-publishing stores it uses.
++CREATE TABLE wiki_pages (
++ id text PRIMARY KEY,
++ slug text NOT NULL UNIQUE,
++ owner text NOT NULL,
++ visibility text NOT NULL DEFAULT 'public',
++ state text NOT NULL DEFAULT 'draft',
++ published_revision integer,
++ published_at bigint,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX wiki_pages_published ON wiki_pages (published_at DESC, id) WHERE state = 'published';
++CREATE TABLE wiki_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ envelope jsonb NOT NULL
++);
++${publishing.schema({ revisions: 'wiki_page', citations: 'wiki', seo: 'wiki_page', authorship: 'wiki_page', indexHooks: 'wiki' })}`,
++
++ 'blog/migrations/0001_initial.sql': () => `${HEADER}-- Mini blog: its own table, then the openvibe-publishing stores it uses.
++CREATE TABLE blog_posts (
++ id text PRIMARY KEY,
++ slug text NOT NULL UNIQUE,
++ author_name text,
++ visibility text NOT NULL DEFAULT 'public',
++ state text NOT NULL DEFAULT 'draft',
++ published_revision integer,
++ published_at bigint,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX blog_posts_published ON blog_posts (published_at DESC, id) WHERE state = 'published';
++${publishing.schema({ revisions: 'blog_post', citations: 'blog_post', taxonomy: 'blog', schedule: 'blog', media: 'blog_post' })}`,
++};
++
++/** [{ file, expected, actual }] */
++function check() {
++ return Object.entries(FILES).map(([rel, build]) => {
++ const file = path.join(__dirname, rel);
++ return { file, expected: build(), actual: fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null };
++ });
++}
++
++module.exports = { check, FILES };
++
++if (require.main === module && process.argv.includes('--write')) {
++ for (const { file, expected } of check()) {
++ fs.mkdirSync(path.dirname(file), { recursive: true });
++ fs.writeFileSync(file, expected);
++ console.log(`wrote ${path.relative(process.cwd(), file)}`);
++ }
++}
ddiff --git a/examples/two-products/wiki/app.js b/examples/two-products/wiki/app.js
iindex 9f05acd..5d4ac5b 100644
---- a/examples/two-products/wiki/app.js
++++ b/examples/two-products/wiki/app.js
@@@ -1,18 +1,20 @@
'use strict';
/**
-- * Mini wiki — an example consumer of openvibe-publishing. It owns its own SQLite file and its own
-- * publication state (wiki_pages); the packages only supply the mechanics.
++ * Mini wiki — an example consumer of openvibe-publishing. It owns its own database (an
++ * openvibe-sdk/db handle: PostgreSQL in production, PGlite here) and its own publication state
++ * (wiki_pages); the packages only supply the mechanics.
*
-- * node examples/two-products/wiki/app.js # http://127.0.0.1:4801 with a temp database
++ * node examples/two-products/wiki/app.js # http://127.0.0.1:4801 with an in-memory PGlite
++ *
++ * The schema is migrations/0001_initial.sql: the product's own tables plus the stores' schema(prefix)
++ * DDL, applied with db.migrate() exactly as a service does (the file is generated by ../migrations.js).
*
* Uses: revisions (wiki_page_*), citations (wiki_citations), seo gate + redirects + sitemap +
-- * Atom/JSON feeds, authorship (AI pages start as drafts), index-hooks (Search index events + product events into wiki_outbox), ssr.
++ * Atom/JSON feeds, authorship (AI pages start as drafts), index-hooks (Search index events + product
++ * events into wiki_outbox, in the same transaction as the change), ssr.
*/
const http = require('http');
const path = require('path');
--const os = require('os');
--const fs = require('fs');
--const Database = require('better-sqlite3');
// The package's public entry points, exactly as a product would import them.
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
@@@ -24,25 +26,10 @@ const { slugify } = require('openvibe-publishing/taxonomy');
const POLICY = { minWords: 30, requireSources: true, minSources: 1 };
--function createWiki({ dbPath, origin = 'https://openvibe.wiki', now = () => Date.now() } = {}) {
-- const db = new Database(dbPath);
-- db.pragma('journal_mode = WAL');
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS wiki_pages (
-- id TEXT PRIMARY KEY,
-- slug TEXT NOT NULL UNIQUE,
-- owner TEXT NOT NULL,
-- visibility TEXT NOT NULL DEFAULT 'public',
-- state TEXT NOT NULL DEFAULT 'draft',
-- published_revision INTEGER,
-- published_at INTEGER,
-- updated_at INTEGER NOT NULL
-- );
-- CREATE TABLE IF NOT EXISTS wiki_outbox (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- envelope TEXT NOT NULL
-- );
-- `);
++/** db: an openvibe-sdk/db handle the product owns. */
++async function createWiki({ db, origin = 'https://openvibe.wiki', now = () => Date.now() } = {}) {
++ const { sql } = db;
++ await db.migrate({ dir: path.join(__dirname, 'migrations'), log: { log() {} } });
const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
const citations = createCitationStore(db, { prefix: 'wiki', now, revisions });
const redirects = seo.createRedirectStore(db, { prefix: 'wiki_page', now });
@@@ -51,24 +38,29 @@ function createWiki({ dbPath, origin = 'https://openvibe.wiki', now = () => Date
const pathOf = (slug) => `/p/${slug}`;
const urlOf = (slug) => seo.canonicalUrl(origin, pathOf(slug));
-- const getPage = (id) => db.prepare('SELECT * FROM wiki_pages WHERE id = ?').get(id);
-- const bySlug = (slug) => db.prepare('SELECT * FROM wiki_pages WHERE slug = ?').get(slug);
++ const getPage = (h, id) => h.maybe(sql`SELECT * FROM wiki_pages WHERE id = ${id}`);
++ const bySlug = (slug) => db.maybe(sql`SELECT * FROM wiki_pages WHERE slug = ${slug}`);
-- function decide(page, rev) {
++ /** The gate decision from facts already read (no query of its own). */
++ function decideWith(page, rev, cites, review) {
const rec = rev.meta.authorship;
return seo.evaluate({
state: page.state, visibility: page.visibility, canonicalUrl: urlOf(page.slug),
-- text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(page.id, rev.number).length,
-- ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
++ text: ssr.markdownToText(rev.content), citationCount: cites.length,
++ ...(rec ? authorship.gateFacts(rec, review) : {}),
}, { policy: POLICY, now: now() });
}
-- function document(page, rev, decision) {
++ async function decide(h, page, rev) {
++ return decideWith(page, rev, await citations.forRevision(h, page.id, rev.number), await reviews.latest(h, page.id, rev.number));
++ }
++
++ async function document(h, page, rev, decision) {
return hooks.buildIndexDocument({
owner: 'wiki', type: 'page', id: page.id, revision: 0, state: page.state, visibility: page.visibility,
canonicalUrl: urlOf(page.slug), title: rev ? rev.fields.title : page.slug, summary: rev ? ssr.markdownToText(rev.content, 160) : null,
body: rev ? ssr.markdownToText(rev.content) : '', decision, acl: { subjects: [page.owner] },
-- authorship: rev && rev.meta.authorship, citations: rev ? citations.forRevision(page.id, rev.number) : [],
++ authorship: rev && rev.meta.authorship, citations: rev ? await citations.forRevision(h, page.id, rev.number) : [],
facets: { content_revision: rev ? rev.number : 0 },
publishedAt: page.published_at, updatedAt: page.updated_at,
});
@@@ -76,20 +68,20 @@ function createWiki({ dbPath, origin = 'https://openvibe.wiki', now = () => Date
/**
* After any change: send Search the current document (or tombstone) when it differs from the
-- * last one sent, and the product event when the publication state moved. Both go to the
-- * outbox in the caller's transaction.
++ * last one sent, and the product event when the publication state moved. The stamp and both
++ * events go into the outbox inside the caller's transaction `t`.
*/
-- function sync(before, page, actor) {
-- const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;
-- const decision = rev ? decide(page, rev) : null;
-- const sentBefore = sequencer.current('wiki', 'page', page.id);
-- const doc = sequencer.stamp(document(page, rev, decision));
++ async function sync(t, before, page, actor) {
++ const rev = page.published_revision ? await revisions.get(t, page.id, page.published_revision) : null;
++ const decision = rev ? await decide(t, page, rev) : null;
++ const sentBefore = await sequencer.current(t, 'wiki', 'page', page.id);
++ const doc = await sequencer.stamp(t, await document(t, page, rev, decision));
const out = [];
if (doc.revision !== sentBefore) out.push(hooks.indexEvent({ document: doc, now: now() }));
const action = hooks.actionFor(before && { state: before.state, visibility: before.visibility, revision: before.published_revision },
{ state: page.state, visibility: page.visibility, revision: page.published_revision });
if (action) out.push(hooks.publicationEvent({ product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0, actor, document: doc, decision, now: now() }));
-- for (const env of out) db.prepare('INSERT INTO wiki_outbox (envelope) VALUES (?)').run(JSON.stringify(env));
++ if (out.length) await t.exec(sql`INSERT INTO wiki_outbox ${sql.insert(out.map((env) => ({ envelope: JSON.stringify(env) })))}`);
return out;
}
@@@ -97,110 +89,111 @@ function createWiki({ dbPath, origin = 'https://openvibe.wiki', now = () => Date
db, revisions, citations, redirects, reviews, policy: POLICY,
createPage({ title, body, author, sources = [], authorship: rec = authorship.record({ mode: 'human', authors: [author] }) }) {
-- return db.transaction(() => {
++ return db.tx(async (t) => {
const id = `pg_${slugify(title)}`;
-- db.prepare('INSERT INTO wiki_pages (id, slug, owner, updated_at) VALUES (?, ?, ?, ?)').run(id, slugify(title), author, now());
-- const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title }, meta: { authorship: rec }, author });
-- citations.attachMany(id, revision.number, sources);
++ await t.exec(sql`INSERT INTO wiki_pages (id, slug, owner, updated_at) VALUES (${id}, ${slugify(title)}, ${author}, ${now()})`);
++ const { revision } = await revisions.create(t, { entityId: id, expectedRevision: 0, content: body, fields: { title }, meta: { authorship: rec }, author });
++ await citations.attachMany(t, id, revision.number, sources);
return { id, revision: revision.number, initial: authorship.initialState(rec) };
-- })();
++ });
},
/** Edit: new revision; `keepSources` carries earlier citations forward, `sources` adds new ones. */
edit(id, { expectedRevision, body, title, author, sources = [], keepSources = [] }) {
-- return db.transaction(() => {
-- const head = revisions.head(id);
-- const { revision } = revisions.create({
++ return db.tx(async (t) => {
++ const head = await revisions.head(t, id);
++ const { revision } = await revisions.create(t, {
entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title },
meta: { authorship: authorship.record({ mode: 'human', authors: [author] }) }, author,
});
-- if (keepSources.length) citations.carryForward({ entityId: id, fromRevision: head.number, toRevision: revision.number, ids: keepSources });
-- citations.attachMany(id, revision.number, sources);
++ if (keepSources.length) await citations.carryForward(t, { entityId: id, fromRevision: head.number, toRevision: revision.number, ids: keepSources });
++ await citations.attachMany(t, id, revision.number, sources);
return revision.number;
-- })();
++ });
},
revert(id, { toRevision, expectedRevision, author }) {
-- return db.transaction(() => {
-- const { revision } = revisions.revert({ entityId: id, toRevision, expectedRevision, author });
-- citations.carryForward({ entityId: id, fromRevision: toRevision, toRevision: revision.number });
++ return db.tx(async (t) => {
++ const { revision } = await revisions.revert(t, { entityId: id, toRevision, expectedRevision, author });
++ await citations.carryForward(t, { entityId: id, fromRevision: toRevision, toRevision: revision.number });
return revision.number;
-- })();
++ });
},
publish(id, { revision, actor }) {
-- return db.transaction(() => {
-- const before = getPage(id);
-- const rev = revisions.get(id, revision);
++ return db.tx(async (t) => {
++ const before = await getPage(t, id);
++ const rev = await revisions.get(t, id, revision);
const rec = rev.meta.authorship;
-- const ok = authorship.canPublish(rec, reviews.latest(id, revision));
++ const ok = authorship.canPublish(rec, await reviews.latest(t, id, revision));
if (!ok.ok) throw Object.assign(new Error(`cannot publish: ${ok.reason}`), { status: 409, code: ok.reason });
-- db.prepare("UPDATE wiki_pages SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ?").run(revision, now(), now(), id);
-- return sync(before, getPage(id), actor);
-- })();
++ const page = await t.one(sql`UPDATE wiki_pages SET state = 'published', published_revision = ${revision}, published_at = COALESCE(published_at, ${now()}), updated_at = ${now()}
++ WHERE id = ${id} RETURNING *`);
++ return sync(t, before, page, actor);
++ });
},
setVisibility(id, visibility, { actor }) {
-- return db.transaction(() => {
-- const before = getPage(id);
-- db.prepare('UPDATE wiki_pages SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id);
-- return sync(before, getPage(id), actor);
-- })();
++ return db.tx(async (t) => {
++ const before = await getPage(t, id);
++ const page = await t.one(sql`UPDATE wiki_pages SET visibility = ${visibility}, updated_at = ${now()} WHERE id = ${id} RETURNING *`);
++ return sync(t, before, page, actor);
++ });
},
rename(id, newTitle) {
-- return db.transaction(() => {
-- const page = getPage(id);
++ return db.tx(async (t) => {
++ const before = await getPage(t, id);
const slug = slugify(newTitle);
-- redirects.recordMove(id, pathOf(page.slug), pathOf(slug));
-- db.prepare('UPDATE wiki_pages SET slug = ?, updated_at = ? WHERE id = ?').run(slug, now(), id);
-- sync(page, getPage(id), 'svc:wiki'); // the canonical URL changed: Search gets a new revision
++ await redirects.recordMove(t, id, pathOf(before.slug), pathOf(slug));
++ const page = await t.one(sql`UPDATE wiki_pages SET slug = ${slug}, updated_at = ${now()} WHERE id = ${id} RETURNING *`);
++ await sync(t, before, page, 'svc:wiki'); // the canonical URL changed: Search gets a new revision
return slug;
-- })();
++ });
},
remove(id, { actor }) {
-- return db.transaction(() => {
-- const before = getPage(id);
-- db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), id);
-- return sync(before, getPage(id), actor);
-- })();
++ return db.tx(async (t) => {
++ const before = await getPage(t, id);
++ const page = await t.one(sql`UPDATE wiki_pages SET state = 'deleted', updated_at = ${now()} WHERE id = ${id} RETURNING *`);
++ return sync(t, before, page, actor);
++ });
},
-- outbox() { return db.prepare('SELECT envelope FROM wiki_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)); },
++ async outbox() { return (await db.many(sql`SELECT envelope FROM wiki_outbox ORDER BY id`)).map((r) => r.envelope); },
-- /** Every page with its live revision and gate decision. */
-- published() {
-- return db.prepare("SELECT * FROM wiki_pages WHERE state = 'published' ORDER BY published_at DESC, id").all().map((page) => {
-- const rev = revisions.get(page.id, page.published_revision);
-- return { page, rev, decision: decide(page, rev) };
-- });
++ /** Every published page with its live revision and gate decision: four queries however many pages (no N+1). */
++ async published() {
++ const pages = await db.many(sql`SELECT * FROM wiki_pages WHERE state = 'published' ORDER BY published_at DESC, id LIMIT 1000`);
++ const refs = pages.map((p) => ({ entityId: p.id, revision: p.published_revision }));
++ const [revs, cites, checks] = await Promise.all([revisions.getMany(refs), citations.forRevisions(refs), reviews.latestMany(refs)]);
++ return pages.map((page, i) => ({ page, rev: revs[i], decision: decideWith(page, revs[i], cites[i], checks[i]) }));
},
-- sitemapXml() {
-- return seo.sitemap(api.published().map(({ page, rev, decision }) => ({ loc: urlOf(page.slug), lastmod: rev.createdAt, decision }))).files[0];
++ async sitemapXml() {
++ return seo.sitemap((await api.published()).map(({ page, rev, decision }) => ({ loc: urlOf(page.slug), lastmod: rev.createdAt, decision }))).files[0];
},
-- feedItems() {
-- return api.published().map(({ page, rev, decision }) => ({
++ async feedItems() {
++ return (await api.published()).map(({ page, rev, decision }) => ({
id: `tag:openvibe.wiki,2026:page/${page.id}`, url: urlOf(page.slug), title: rev.fields.title,
summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content, { rel: 'noopener' }),
published: page.published_at, updated: rev.createdAt, decision,
}));
},
-- atom() {
++ async atom() {
// Atom needs a feed-level <updated>: the last real change to any page (never "now").
-- const last = db.prepare('SELECT MAX(updated_at) AS t FROM wiki_pages').get().t;
-- return seo.atomFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.atom`, updated: last }, api.feedItems());
++ const last = await db.value(sql`SELECT max(updated_at) AS t FROM wiki_pages`);
++ return seo.atomFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.atom`, updated: last }, await api.feedItems());
},
-- jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.json` }, api.feedItems()); },
++ async jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.json` }, await api.feedItems()); },
-- renderPage(page) {
-- const rev = revisions.get(page.id, page.published_revision);
-- const decision = decide(page, rev);
-- const cites = citations.forRevision(page.id, rev.number);
-- const disclosure = authorship.disclosure(rev.meta.authorship, reviews.latest(page.id, rev.number));
++ async renderPage(page) {
++ const rev = await revisions.get(page.id, page.published_revision);
++ const decision = await decide(db, page, rev);
++ const cites = await citations.forRevision(page.id, rev.number);
++ const disclosure = authorship.disclosure(rev.meta.authorship, await reviews.latest(page.id, rev.number));
const crumbs = [{ name: 'Wiki', url: `${origin}/` }, { name: rev.fields.title, url: urlOf(page.slug) }];
const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',
jsonLd: [seo.structuredData.article({ headline: rev.fields.title, url: urlOf(page.slug), dateModified: rev.createdAt,
@@@ -216,17 +209,21 @@ ${ssr.raw(ssr.renderMarkdown(rev.content, { rel: 'noopener' }))}
/** Node http handler: pages, 301/410 history, sitemap, feeds. No JavaScript anywhere. */
handler(req, res) {
-- const url = new URL(req.url, origin);
const send = (status, type, body, headers = {}) => { res.writeHead(status, { 'Content-Type': type, ...headers }); res.end(body); };
-- if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());
-- if (url.pathname === '/feed.atom') return send(200, 'application/atom+xml', api.atom());
-- if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));
++ api.route(req, send).catch((err) => { if (!res.headersSent) send(500, 'text/plain', 'Server error'); console.error(err); });
++ },
++
++ async route(req, send) {
++ const url = new URL(req.url, origin);
++ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', await api.sitemapXml());
++ if (url.pathname === '/feed.atom') return send(200, 'application/atom+xml', await api.atom());
++ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(await api.jsonFeedDoc()));
if (url.pathname === '/robots.txt') return send(200, 'text/plain', seo.robotsTxt({ sitemaps: [`${origin}/sitemap.xml`] }));
const m = url.pathname.match(/^\/p\/([^/]+)(\/history)?$/);
if (!m) return send(404, 'text/plain', 'Not found');
-- const page = bySlug(decodeURIComponent(m[1]));
++ const page = await bySlug(decodeURIComponent(m[1]));
if (!page) {
-- const r = redirects.resolve(url.pathname.replace(/\/history$/, ''), { currentPath: (id) => { const p = getPage(id); return p && p.state !== 'deleted' ? pathOf(p.slug) : null; } });
++ const r = await redirects.resolve(url.pathname.replace(/\/history$/, ''), { currentPath: async (id) => { const p = await getPage(db, id); return p && p.state !== 'deleted' ? pathOf(p.slug) : null; } });
if (r && r.status === 301) return send(301, 'text/plain', 'Moved', { Location: r.location + (m[2] || '') });
if (r && r.status === 410) return send(410, 'text/plain', 'Gone');
return send(404, 'text/plain', 'Not found');
@@@ -234,13 +231,14 @@ ${ssr.raw(ssr.renderMarkdown(rev.content, { rel: 'noopener' }))}
if (page.state === 'deleted') return send(410, 'text/plain', 'Gone');
if (page.state !== 'published' || page.visibility === 'private' || page.visibility === 'gated') return send(404, 'text/plain', 'Not found');
if (m[2]) {
-- const list = revisions.list(page.id).map((r) => ssr.html`<li>r${r.number} ${r.kind} ${ssr.raw(ssr.timeTag(r.createdAt))}</li>`);
++ const list = (await revisions.list(page.id)).map((r) => ssr.html`<li>r${r.number} ${r.kind} ${ssr.raw(ssr.timeTag(r.createdAt))}</li>`);
return send(200, 'text/html; charset=utf-8', String(ssr.html`<!doctype html><title>History</title><meta name="robots" content="noindex"><ol>${list}</ol>`));
}
-- return send(200, 'text/html; charset=utf-8', api.renderPage(page));
++ return send(200, 'text/html; charset=utf-8', await api.renderPage(page));
},
-- close() { db.close(); },
++ /** The database belongs to whoever opened it; the app holds nothing else. */
++ close() {},
};
return api;
}
@@@ -248,13 +246,16 @@ ${ssr.raw(ssr.renderMarkdown(rev.content, { rel: 'noopener' }))}
module.exports = { createWiki };
if (require.main === module) {
-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-wiki-'));
-- const wiki = createWiki({ dbPath: path.join(dir, 'wiki.db'), origin: 'http://127.0.0.1:4801' });
-- const { id } = wiki.createPage({
-- title: 'Rye', author: 'usr_01J8Z6Q3KX0000000000000000',
-- body: 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.',
-- sources: [{ url: 'https://en.wikipedia.org/wiki/Rye', title: 'Rye — Wikipedia', licenseNote: 'CC BY-SA 4.0' }],
-- });
-- wiki.publish(id, { revision: 1, actor: 'usr_01J8Z6Q3KX0000000000000000' });
-- http.createServer(wiki.handler).listen(4801, '127.0.0.1', () => console.log(`mini wiki on http://127.0.0.1:4801/p/rye (db ${dir}/wiki.db)`));
++ (async () => {
++ const { createDb } = require('openvibe-sdk/db');
++ const db = createDb({ pglite: true, service: 'mini-wiki' });
++ const wiki = await createWiki({ db, origin: 'http://127.0.0.1:4801' });
++ const { id } = await wiki.createPage({
++ title: 'Rye', author: 'usr_01J8Z6Q3KX0000000000000000',
++ body: 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.',
++ sources: [{ url: 'https://en.wikipedia.org/wiki/Rye', title: 'Rye — Wikipedia', licenseNote: 'CC BY-SA 4.0' }],
++ });
++ await wiki.publish(id, { revision: 1, actor: 'usr_01J8Z6Q3KX0000000000000000' });
++ http.createServer(wiki.handler).listen(4801, '127.0.0.1', () => console.log('mini wiki on http://127.0.0.1:4801/p/rye (in-memory PGlite)'));
++ })().catch((err) => { console.error(err); process.exit(1); });
}
ddiff --git a/examples/two-products/wiki/migrations/0001_initial.sql b/examples/two-products/wiki/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..f8e61da
---- /dev/null
++++ b/examples/two-products/wiki/migrations/0001_initial.sql
@@@ -0,0 +1,150 @@
++-- phase: expand
++-- Mini wiki: its own tables, then the openvibe-publishing stores it uses.
++CREATE TABLE wiki_pages (
++ id text PRIMARY KEY,
++ slug text NOT NULL UNIQUE,
++ owner text NOT NULL,
++ visibility text NOT NULL DEFAULT 'public',
++ state text NOT NULL DEFAULT 'draft',
++ published_revision integer,
++ published_at bigint,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX wiki_pages_published ON wiki_pages (published_at DESC, id) WHERE state = 'published';
++CREATE TABLE wiki_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ envelope jsonb NOT NULL
++);
++-- openvibe-publishing/revisions (prefix wiki_page)
++CREATE TABLE IF NOT EXISTS wiki_page_revisions (
++ id text PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ number integer NOT NULL CHECK (number >= 1),
++ parent_id text,
++ parent_number integer,
++ kind text NOT NULL CHECK (kind IN ('edit','revert','import')),
++ reverted_to integer,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ content_hash text NOT NULL,
++ author text,
++ message text,
++ created_at bigint NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS wiki_page_revisions_entity_num ON wiki_page_revisions (entity_id, number);
++CREATE TABLE IF NOT EXISTS wiki_page_drafts (
++ entity_id text COLLATE "C" NOT NULL,
++ owner text COLLATE "C" NOT NULL,
++ base_revision integer NOT NULL,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (entity_id, owner)
++);
++CREATE INDEX IF NOT EXISTS wiki_page_drafts_updated ON wiki_page_drafts (entity_id, updated_at DESC, owner);
++CREATE TABLE IF NOT EXISTS wiki_page_revision_purges (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++CREATE OR REPLACE FUNCTION wiki_page_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'wiki_page_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'wiki_page_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER wiki_page_revisions_no_update BEFORE UPDATE ON wiki_page_revisions FOR EACH ROW EXECUTE FUNCTION wiki_page_revisions_guard();
++CREATE OR REPLACE TRIGGER wiki_page_revisions_no_delete BEFORE DELETE ON wiki_page_revisions FOR EACH ROW EXECUTE FUNCTION wiki_page_revisions_guard();
++
++-- openvibe-publishing/citations (prefix wiki)
++CREATE TABLE IF NOT EXISTS wiki_citations (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL CHECK (revision >= 1),
++ anchor text,
++ source_item_id text COLLATE "C",
++ url text,
++ title text,
++ retrieved_at timestamptz,
++ quote_text text,
++ quote_start integer,
++ quote_end integer,
++ license_note text,
++ carried_from bigint REFERENCES wiki_citations(id),
++ attached_by text,
++ attached_at bigint NOT NULL,
++ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
++);
++CREATE INDEX IF NOT EXISTS wiki_citations_rev ON wiki_citations (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS wiki_citations_source ON wiki_citations (source_item_id, entity_id, revision, id) WHERE source_item_id IS NOT NULL;
++CREATE INDEX IF NOT EXISTS wiki_citations_carried ON wiki_citations (carried_from) WHERE carried_from IS NOT NULL;
++CREATE TABLE IF NOT EXISTS wiki_citation_purges (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++CREATE OR REPLACE FUNCTION wiki_citations_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'wiki_citations rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM wiki_citation_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'wiki_citations rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER wiki_citations_no_update BEFORE UPDATE ON wiki_citations FOR EACH ROW EXECUTE FUNCTION wiki_citations_guard();
++CREATE OR REPLACE TRIGGER wiki_citations_no_delete BEFORE DELETE ON wiki_citations FOR EACH ROW EXECUTE FUNCTION wiki_citations_guard();
++
++-- openvibe-publishing/seo (prefix wiki_page)
++CREATE TABLE IF NOT EXISTS wiki_page_redirects (
++ from_path text COLLATE "C" PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ reason text,
++ created_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS wiki_page_redirects_entity ON wiki_page_redirects (entity_id, created_at, from_path);
++
++-- openvibe-publishing/authorship (prefix wiki_page)
++CREATE TABLE IF NOT EXISTS wiki_page_reviews (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ reviewer text NOT NULL,
++ decision text NOT NULL CHECK (decision IN ('approved','rejected')),
++ note text,
++ reviewed_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS wiki_page_reviews_rev ON wiki_page_reviews (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS wiki_page_reviews_entity ON wiki_page_reviews (entity_id, id);
++CREATE OR REPLACE FUNCTION wiki_page_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'wiki_page_reviews rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER wiki_page_reviews_no_update BEFORE UPDATE ON wiki_page_reviews FOR EACH ROW EXECUTE FUNCTION wiki_page_reviews_guard();
++
++-- openvibe-publishing/index-hooks (prefix wiki)
++CREATE TABLE IF NOT EXISTS wiki_index_revisions (
++ owner text COLLATE "C" NOT NULL,
++ type text COLLATE "C" NOT NULL,
++ id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ hash text NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (owner, type, id)
++);
ddiff --git a/index.js b/index.js
iindex a0eaa7c..41a9445 100644
---- a/index.js
++++ b/index.js
@@@ -4,6 +4,9 @@
* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
* …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/ai
* This root export loads each one lazily, on first access.
++ *
++ * schema({ revisions: 'wiki_page', citations: 'wiki', … }) is the DDL of every store a product uses,
++ * for its migration file (keys are module names; a value is a prefix or a list of prefixes).
*/
const MODULES = {
revisions: './lib/revisions',
@@@ -19,11 +22,34 @@ const MODULES = {
ai: './lib/ai',
};
++/** Which function gives each storing module's DDL. */
++const SCHEMAS = {
++ revisions: (p) => require('./lib/revisions').schema(p),
++ citations: (p) => require('./lib/citations').schema(p),
++ media: (p) => require('./lib/media').schema(p),
++ discussion: (p) => require('./lib/discussion').schema(p),
++ schedule: (p) => require('./lib/schedule').schema(p),
++ authorship: (p) => require('./lib/authorship').schema(p),
++ taxonomy: (p) => require('./lib/taxonomy').schema(p),
++ seo: (p) => require('./lib/seo').redirectsSchema(p),
++ indexHooks: (p) => require('./lib/index-hooks').sequencerSchema(p),
++};
++
++function schema(stores = {}) {
++ const parts = [];
++ for (const [name, prefixes] of Object.entries(stores)) {
++ if (!SCHEMAS[name]) throw new TypeError(`schema: no store in module "${name}" (one of ${Object.keys(SCHEMAS).join(', ')})`);
++ for (const p of [].concat(prefixes)) parts.push(`-- openvibe-publishing/${name === 'indexHooks' ? 'index-hooks' : name} (prefix ${p})\n${SCHEMAS[name](p)}`);
++ }
++ return parts.join('\n');
++}
++
const api = {};
for (const [name, path] of Object.entries(MODULES)) {
Object.defineProperty(api, name, { enumerable: true, get: () => require(path) });
}
Object.defineProperty(api, 'PublishingError', { enumerable: true, get: () => require('./lib/internal').PublishingError });
++Object.defineProperty(api, 'schema', { enumerable: true, value: schema });
Object.defineProperty(api, 'version', { enumerable: true, value: require('./package.json').version });
module.exports = api;
ddiff --git a/lib/authorship.js b/lib/authorship.js
iindex 3363811..e49e694 100644
---- a/lib/authorship.js
++++ b/lib/authorship.js
@@@ -8,9 +8,9 @@
* const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 3, runId: 'run_…' }, source: { label: 'Sources item src_…' } });
* authorship.initialState(rec) // → { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' }
* authorship.disclosure(rec).short // → 'AI-generated'
-- * const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page' }); // wiki_page_reviews
-- * reviews.record({ entityId, revision: 4, reviewer: 'usr_…', decision: 'approved' });
-- * authorship.gateFacts(rec, reviews.latest(entityId, 4)) // → { authorship: { mode: 'ai', reviewed: true } }
++ * const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page' }); // wiki_page_reviews (schema('wiki_page'))
++ * await reviews.record({ entityId, revision: 4, reviewer: 'usr_…', decision: 'approved' });
++ * authorship.gateFacts(rec, await reviews.latest(entityId, 4)) // → { authorship: { mode: 'ai', reviewed: true } }
*
* Modes (§15.12 names in brackets): human; ai [AI-generated]; hybrid [AI-assisted: a person wrote or
* cut it from an AI suggestion]; imported [legacy/imported, keeps the original author].
@@@ -23,7 +23,9 @@
* reviews are append-only rows, so the revision itself stays immutable.
* Authorship is data, not a sentence: surfaces render the disclosure from this record.
*/
--const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber } = require('./internal');
++const {
++ PublishingError, assertDb, sqlOf, bindHandles, boundedLimit, toInt4, revisionRefs, refKey, assertPrefix, clockOf, assertEntityId, assertRevisionNumber, immutableTriggers,
++} = require('./internal');
const MODES = ['human', 'ai', 'hybrid', 'imported'];
const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -105,46 +107,73 @@ function disclosure(rec, review = null, { sourceLabel } = {}) {
}
}
--/** Append-only review log: <prefix>_reviews. */
++/** The review log's DDL for one prefix (idempotent), for the service's migration file. */
++function schema(prefix) {
++ assertPrefix(prefix);
++ const R = `${prefix}_reviews`;
++ return `CREATE TABLE IF NOT EXISTS ${R} (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ reviewer text NOT NULL,
++ decision text NOT NULL CHECK (decision IN ('approved','rejected')),
++ note text,
++ reviewed_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS ${R}_rev ON ${R} (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS ${R}_entity ON ${R} (entity_id, id);
++${immutableTriggers(R)}`;
++}
++
++/** Append-only review log: <prefix>_reviews (UPDATE aborts in a PL/pgSQL trigger). */
function createReviewLog(db, { prefix, now } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const R = `${prefix}_reviews`;
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${R} (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- entity_id TEXT NOT NULL,
-- revision INTEGER NOT NULL,
-- reviewer TEXT NOT NULL,
-- decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),
-- note TEXT,
-- reviewed_at INTEGER NOT NULL
-- );
-- CREATE INDEX IF NOT EXISTS ${R}_rev ON ${R} (entity_id, revision, id);
-- CREATE TRIGGER IF NOT EXISTS ${R}_no_update BEFORE UPDATE ON ${R} BEGIN SELECT RAISE(ABORT, '${R} rows are immutable'); END;
-- `);
++ const r$ = sql.ident(R);
const shape = (r) => (r ? { id: r.id, entityId: r.entity_id, revision: r.revision, reviewer: r.reviewer, decision: r.decision, note: r.note, reviewedAt: new Date(r.reviewed_at).toISOString() } : null);
-- const q = {
-- insert: db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, decision, note, reviewed_at) VALUES (?, ?, ?, ?, ?, ?)`),
-- latest: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`),
-- all: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id`),
-- byId: db.prepare(`SELECT * FROM ${R} WHERE id = ?`),
-- };
-- return {
-- table: R,
++ const impl = {
/** A person's review of one revision. The reviewer must be a usr_ subject. */
-- record({ entityId, revision, reviewer, decision, note = null } = {}) {
++ async record(h, { entityId, revision, reviewer, decision, note = null } = {}) {
assertEntityId(entityId);
assertRevisionNumber(revision, 'revision');
++ if (revision > 2147483647) throw new TypeError('revision is out of range');
if (!USER_RE.test(String(reviewer))) throw new PublishingError(400, 'review.reviewer_not_person', 'Only a person (usr_ subject) can review');
if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');
-- const info = q.insert.run(entityId, revision, reviewer, decision, note == null ? null : String(note).slice(0, 2000), clock());
-- return shape(q.byId.get(info.lastInsertRowid));
++ return shape(await h.one(sql`INSERT INTO ${r$} (entity_id, revision, reviewer, decision, note, reviewed_at)
++ VALUES (${entityId}, ${revision}, ${reviewer}, ${decision}, ${note == null ? null : String(note).slice(0, 2000)}, ${clock()})
++ RETURNING *`));
++ },
++ async latest(h, entityId, revision) {
++ const n = toInt4(revision);
++ if (n == null) return null;
++ return shape(await h.maybe(sql`SELECT * FROM ${r$} WHERE entity_id = ${assertEntityId(entityId)} AND revision = ${n} ORDER BY id DESC LIMIT 1`));
++ },
++ /** The latest review of several revisions in one query: refs [{ entityId, revision }] → reviews (or null), same order. */
++ async latestMany(h, refs = []) {
++ const keys = revisionRefs(refs).filter((k) => k.number != null);
++ const by = new Map();
++ if (keys.length) {
++ const rows = await h.many(sql`SELECT DISTINCT ON (r.entity_id, r.revision) r.*
++ FROM unnest(${keys.map((k) => k.entityId)}::text[], ${keys.map((k) => k.number)}::integer[]) AS k(entity_id, revision)
++ JOIN ${r$} r ON r.entity_id = k.entity_id AND r.revision = k.revision
++ ORDER BY r.entity_id, r.revision, r.id DESC`);
++ for (const r of rows) by.set(refKey(r.entity_id, r.revision), shape(r));
++ }
++ return revisionRefs(refs).map((k) => by.get(refKey(k.entityId, k.number)) || null);
++ },
++ async history(h, entityId, { limit = 1000 } = {}) {
++ return (await h.many(sql`SELECT * FROM ${r$} WHERE entity_id = ${assertEntityId(entityId)} ORDER BY id LIMIT ${boundedLimit(limit)}`)).map(shape);
},
-- latest(entityId, revision) { return shape(q.latest.get(assertEntityId(entityId), revision)); },
-- history(entityId) { return q.all.all(assertEntityId(entityId)).map(shape); },
};
++ const api = bindHandles(db, impl);
++ api.table = R;
++ api.schema = () => schema(prefix);
++ /** Create the table where the handle may (tests, PGlite); services put schema() in a migration. */
++ api.ensureSchema = async () => { await db.query(schema(prefix)); return api; };
++ return api;
}
--module.exports = { MODES, record, needsReview, isReviewed, initialState, canPublish, gateFacts, disclosure, createReviewLog };
++module.exports = { MODES, record, needsReview, isReviewed, initialState, canPublish, gateFacts, disclosure, createReviewLog, schema };
ddiff --git a/lib/citations.js b/lib/citations.js
iindex 77cb46d..8035f62 100644
---- a/lib/citations.js
++++ b/lib/citations.js
@@@ -2,23 +2,30 @@
/**
* openvibe-publishing/citations — source references attached to one specific revision.
*
-- * const { createCitationStore } = require('openvibe-publishing/citations');
++ * const { createCitationStore, schema } = require('openvibe-publishing/citations');
* const cites = createCitationStore(db, { prefix: 'wiki', revisions }); // wiki_citations
-- * cites.attach({ entityId: 'pg_1', revision: 2, url: 'https://example.org/a', retrievedAt: '2026-09-20T10:00:00Z',
-- * quote: { text: '…', start: 120, end: 180 }, licenseNote: 'CC BY 4.0', sourceItemId: 'src_…' });
-- * cites.carryForward({ entityId: 'pg_1', fromRevision: 2, toRevision: 3 }); // reuse in r3
-- * cites.forRevision('pg_1', 2); // still there after r3 dropped them
++ * await cites.attach({ entityId: 'pg_1', revision: 2, url: 'https://example.org/a', retrievedAt: '2026-09-20T10:00:00Z',
++ * quote: { text: '…', start: 120, end: 180 }, licenseNote: 'CC BY 4.0', sourceItemId: 'src_…' });
++ * await cites.carryForward({ entityId: 'pg_1', fromRevision: 2, toRevision: 3 }); // reuse in r3
++ * await cites.forRevision('pg_1', 2); // still there after r3 dropped them
*
* Rules:
* - A citation belongs to (entity, revision). It is never dropped: the table is append-only
-- * (UPDATE and DELETE abort in SQLite triggers), so a later revision that does not reuse a
++ * (UPDATE and DELETE abort in a PL/pgSQL trigger), so a later revision that does not reuse a
* source leaves the earlier revision's citations intact. Only purgeEntity (audited) removes rows.
* - Every citation names a source: a Sources item id, an http(s) URL, or both.
* - Nothing is filled in: a missing retrieved_at, title or license note stays null. This module
* never stamps "now" as a retrieval time.
* - If a revisions store is passed, attach() refuses revisions that do not exist.
++ * Several citations go in one INSERT (attachMany, carryForward), never one statement per row.
*/
--const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers } = require('./internal');
++const {
++ PublishingError, assertDb, sqlOf, bindHandles, lockKey, boundedLimit, toId, toInt4, revisionRefs, refKey, assertPrefix, clockOf,
++ assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers,
++} = require('./internal');
++
++const COLUMNS = ['entity_id', 'revision', 'anchor', 'source_item_id', 'url', 'title', 'retrieved_at', 'quote_text', 'quote_start',
++ 'quote_end', 'license_note', 'carried_from', 'attached_by', 'attached_at'];
function shape(row) {
if (!row) return null;
@@@ -32,7 +39,7 @@ function shape(row) {
sourceItemId: row.source_item_id,
url: row.url,
title: row.title,
-- retrievedAt: row.retrieved_at,
++ retrievedAt: row.retrieved_at == null ? null : new Date(row.retrieved_at).toISOString(),
quote,
licenseNote: row.license_note,
carriedFrom: row.carried_from,
@@@ -56,70 +63,80 @@ function checkQuote(quote) {
const start = quote.start == null ? null : Number(quote.start);
const end = quote.end == null ? null : Number(quote.end);
if ((start == null) !== (end == null)) throw new TypeError('quote.start and quote.end go together');
-- if (start != null && (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start)) throw new TypeError('quote span must be 0 <= start <= end');
++ if (start != null && (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > 2147483647)) throw new TypeError('quote span must be 0 <= start <= end');
if (text == null && start == null) return { text: null, start: null, end: null };
return { text, start, end };
}
++/** The DDL for one prefix (idempotent), for the service's migration file. */
++function schema(prefix) {
++ assertPrefix(prefix);
++ const C = `${prefix}_citations`;
++ const P = `${prefix}_citation_purges`;
++ return `CREATE TABLE IF NOT EXISTS ${C} (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL CHECK (revision >= 1),
++ anchor text,
++ source_item_id text COLLATE "C",
++ url text,
++ title text,
++ retrieved_at timestamptz,
++ quote_text text,
++ quote_start integer,
++ quote_end integer,
++ license_note text,
++ carried_from bigint REFERENCES ${C}(id),
++ attached_by text,
++ attached_at bigint NOT NULL,
++ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
++);
++CREATE INDEX IF NOT EXISTS ${C}_rev ON ${C} (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS ${C}_source ON ${C} (source_item_id, entity_id, revision, id) WHERE source_item_id IS NOT NULL;
++CREATE INDEX IF NOT EXISTS ${C}_carried ON ${C} (carried_from) WHERE carried_from IS NOT NULL;
++CREATE TABLE IF NOT EXISTS ${P} (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++${immutableTriggers(C, P)}`;
++}
++
function createCitationStore(db, { prefix, now, revisions = null } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const C = `${prefix}_citations`;
const P = `${prefix}_citation_purges`;
++ const c$ = sql.ident(C);
++ const p$ = sql.ident(P);
++ const lock = (t, entityId) => lockKey(t, sql, `${C}:${entityId}`);
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${C} (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- entity_id TEXT NOT NULL,
-- revision INTEGER NOT NULL CHECK (revision >= 1),
-- anchor TEXT,
-- source_item_id TEXT,
-- url TEXT,
-- title TEXT,
-- retrieved_at TEXT,
-- quote_text TEXT,
-- quote_start INTEGER,
-- quote_end INTEGER,
-- license_note TEXT,
-- carried_from INTEGER REFERENCES ${C}(id),
-- attached_by TEXT,
-- attached_at INTEGER NOT NULL,
-- CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
-- );
-- CREATE INDEX IF NOT EXISTS ${C}_rev ON ${C} (entity_id, revision);
-- CREATE INDEX IF NOT EXISTS ${C}_source ON ${C} (source_item_id);
-- CREATE TABLE IF NOT EXISTS ${P} (
-- entity_id TEXT PRIMARY KEY,
-- reason TEXT NOT NULL,
-- purged_by TEXT,
-- purged_at INTEGER NOT NULL
-- );
-- ${immutableTriggers(C, P)}
-- `);
--
-- const q = {
-- insert: db.prepare(`INSERT INTO ${C} (entity_id, revision, anchor, source_item_id, url, title, retrieved_at, quote_text, quote_start, quote_end, license_note, carried_from, attached_by, attached_at)
-- VALUES (@entity_id, @revision, @anchor, @source_item_id, @url, @title, @retrieved_at, @quote_text, @quote_start, @quote_end, @license_note, @carried_from, @attached_by, @attached_at)`),
-- byId: db.prepare(`SELECT * FROM ${C} WHERE id = ?`),
-- forRevision: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? AND revision = ? ORDER BY id`),
-- history: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? ORDER BY revision, id`),
-- bySource: db.prepare(`SELECT * FROM ${C} WHERE source_item_id = ? ORDER BY entity_id, revision, id`),
-- purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),
-- };
--
-- function checkRevision(entityId, revision) {
++ function checkRevisionNumber(revision) {
assertRevisionNumber(revision, 'revision');
if (revision < 1) throw new TypeError('revision must be >= 1');
-- if (revisions && !revisions.get(entityId, revision)) throw new PublishingError(404, 'revision.not_found', `No revision ${revision} of ${entityId}`);
++ if (revision > 2147483647) throw new TypeError('revision is out of range');
++ }
++
++ /** Inside the entity's lock: the revision exists (when a revisions store is known) and the entity is not purged. */
++ async function checkTarget(t, entityId, revision, { purged = true } = {}) {
++ if (revisions) {
++ const ok = typeof revisions.exists === 'function' ? await revisions.exists(t, entityId, revision) : Boolean(await revisions.get(t, entityId, revision));
++ if (!ok) throw new PublishingError(404, 'revision.not_found', `No revision ${revision} of ${entityId}`);
++ }
++ if (purged && await t.maybe(sql`SELECT 1 AS ok FROM ${p$} WHERE entity_id = ${entityId}`)) {
++ throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);
++ }
}
-- function insert(entityId, revision, c, carriedFrom = null) {
++ function toRow(entityId, revision, c, at, carriedFrom = null) {
const sourceItemId = c.sourceItemId == null || c.sourceItemId === '' ? null : String(c.sourceItemId).slice(0, 128);
const url = checkUrl(c.url);
if (!sourceItemId && !url) throw new PublishingError(400, 'citation.no_source', 'A citation needs a Sources item id or an http(s) URL');
const quote = checkQuote(c.quote);
-- const row = {
++ return {
entity_id: entityId,
revision,
anchor: c.anchor == null ? null : String(c.anchor).slice(0, 100),
@@@ -133,64 +150,138 @@ function createCitationStore(db, { prefix, now, revisions = null } = {}) {
license_note: c.licenseNote == null ? null : String(c.licenseNote).slice(0, 1000),
carried_from: carriedFrom,
attached_by: c.attachedBy == null ? null : String(c.attachedBy),
-- attached_at: clock(),
++ attached_at: at,
};
-- return shape(q.byId.get(q.insert.run(row).lastInsertRowid));
}
-- const api = {
-- table: C,
++ /** One INSERT for any number of rows; identities follow the input order. */
++ async function insertRows(t, rows) {
++ if (!rows.length) return [];
++ const out = await t.many(sql`INSERT INTO ${c$} ${sql.insert(rows, COLUMNS)} RETURNING *`);
++ return out.sort((a, b) => a.id - b.id).map(shape);
++ }
++ const impl = {
/** Attach one citation to (entityId, revision). */
-- attach({ entityId, revision, ...citation } = {}) {
++ async attach(h, { entityId, revision, ...citation } = {}) {
assertEntityId(entityId);
-- checkRevision(entityId, revision);
-- if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);
-- return insert(entityId, revision, citation);
++ checkRevisionNumber(revision);
++ const row = toRow(entityId, revision, citation, clock());
++ return h.tx(async (t) => {
++ await lock(t, entityId);
++ await checkTarget(t, entityId, revision);
++ return (await insertRows(t, [row]))[0];
++ });
},
-- /** Attach several in one transaction. */
-- attachMany(entityId, revision, list = []) {
++ /** Attach several in one transaction and one INSERT. */
++ async attachMany(h, entityId, revision, list = []) {
assertEntityId(entityId);
-- checkRevision(entityId, revision);
-- if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);
-- return db.transaction(() => list.map((c) => insert(entityId, revision, c)))();
++ checkRevisionNumber(revision);
++ const at = clock();
++ const rows = list.map((c) => toRow(entityId, revision, c || {}, at));
++ return h.tx(async (t) => {
++ await lock(t, entityId);
++ await checkTarget(t, entityId, revision);
++ return insertRows(t, rows);
++ });
},
/**
* Reuse citations of `fromRevision` in `toRevision` (all, or only `ids`). New rows point back
-- * with carried_from; the originals are untouched.
++ * with carried_from; the originals are untouched. One INSERT … SELECT.
*/
-- carryForward({ entityId, fromRevision, toRevision, ids = null, attachedBy = null } = {}) {
++ async carryForward(h, { entityId, fromRevision, toRevision, ids = null, attachedBy = null } = {}) {
assertEntityId(entityId);
-- checkRevision(entityId, toRevision);
-- return db.transaction(() => {
-- const wanted = ids ? new Set(ids.map(Number)) : null;
-- return q.forRevision.all(entityId, fromRevision)
-- .filter((r) => !wanted || wanted.has(r.id))
-- .map((r) => {
-- const c = shape(r);
-- return insert(entityId, toRevision, { ...c, attachedBy: attachedBy || c.attachedBy }, r.carried_from || r.id);
-- });
-- })();
++ checkRevisionNumber(toRevision);
++ const from = toInt4(fromRevision);
++ const wanted = ids ? ids.map(toId).filter((x) => x != null) : null;
++ return h.tx(async (t) => {
++ await lock(t, entityId);
++ await checkTarget(t, entityId, toRevision, { purged: false });
++ if (from == null || (wanted && !wanted.length)) return [];
++ const rows = await t.many(sql`INSERT INTO ${c$} (${sql.join(COLUMNS.map(sql.ident))})
++ SELECT entity_id, ${toRevision}::integer, anchor, source_item_id, url, title, retrieved_at, quote_text, quote_start, quote_end,
++ license_note, COALESCE(carried_from, id), COALESCE(${attachedBy == null ? null : String(attachedBy)}::text, attached_by), ${clock()}::bigint
++ FROM ${c$}
++ WHERE entity_id = ${entityId} AND revision = ${from}${wanted ? sql` AND id = ANY(${wanted}::bigint[])` : sql``}
++ ORDER BY id
++ RETURNING *`);
++ return rows.sort((a, b) => a.id - b.id).map(shape);
++ });
++ },
++
++ async get(h, id) {
++ const n = toId(id);
++ return n == null ? null : shape(await h.maybe(sql`SELECT * FROM ${c$} WHERE id = ${n}`));
++ },
++
++ async forRevision(h, entityId, revision, { limit = 1000 } = {}) {
++ const r = toInt4(revision);
++ if (r == null) return [];
++ return (await h.many(sql`SELECT * FROM ${c$} WHERE entity_id = ${assertEntityId(entityId)} AND revision = ${r} ORDER BY id LIMIT ${boundedLimit(limit)}`)).map(shape);
++ },
++
++ /**
++ * The citations of several revisions in one query (feeds, sitemaps, gate facts for a list):
++ * refs [{ entityId, revision }] (up to 1000) → an array of citation lists in the same order.
++ */
++ async forRevisions(h, refs = [], { limit = 10000 } = {}) {
++ const keys = revisionRefs(refs).filter((k) => k.number != null);
++ const by = new Map();
++ if (keys.length) {
++ const rows = await h.many(sql`SELECT c.* FROM unnest(${keys.map((k) => k.entityId)}::text[], ${keys.map((k) => k.number)}::integer[]) AS k(entity_id, revision)
++ JOIN ${c$} c ON c.entity_id = k.entity_id AND c.revision = k.revision
++ ORDER BY c.entity_id, c.revision, c.id LIMIT ${boundedLimit(limit, 10000, 10000)}`);
++ for (const r of rows) {
++ const key = refKey(r.entity_id, r.revision);
++ if (!by.has(key)) by.set(key, []);
++ by.get(key).push(shape(r));
++ }
++ }
++ return revisionRefs(refs).map((k) => by.get(refKey(k.entityId, k.number)) || []);
},
-- get(id) { return shape(q.byId.get(Number(id))); },
-- forRevision(entityId, revision) { return q.forRevision.all(assertEntityId(entityId), revision).map(shape); },
/** Every citation any revision of the entity ever had, oldest revision first. */
-- history(entityId) { return q.history.all(assertEntityId(entityId)).map(shape); },
-- /** Where a Sources item is cited — for correction/removal propagation (a source changed → revise). */
-- bySourceItem(sourceItemId) { return q.bySource.all(String(sourceItemId)).map(shape); },
++ async history(h, entityId, { limit = 1000 } = {}) {
++ return (await h.many(sql`SELECT * FROM ${c$} WHERE entity_id = ${assertEntityId(entityId)} ORDER BY revision, id LIMIT ${boundedLimit(limit)}`)).map(shape);
++ },
++
++ /**
++ * Where a Sources item is cited — for correction/removal propagation (a source changed → revise).
++ * Keyset pages: pass the last citation of a page as `after` for the next one.
++ */
++ async bySourceItem(h, sourceItemId, { limit = 500, after = null } = {}) {
++ let cursor = sql``;
++ if (after) {
++ const id = toId(after.id);
++ const rev = toInt4(after.revision);
++ if (id == null || rev == null || typeof after.entityId !== 'string') throw new TypeError('after must be a citation from the previous page');
++ cursor = sql` AND (entity_id, revision, id) > (${after.entityId}, ${rev}::integer, ${id}::bigint)`;
++ }
++ return (await h.many(sql`SELECT * FROM ${c$} WHERE source_item_id = ${String(sourceItemId)}${cursor}
++ ORDER BY entity_id, revision, id LIMIT ${boundedLimit(limit, 500, 10000)}`)).map(shape);
++ },
/** Audited erasure of every citation of one entity. */
-- purgeEntity(entityId, { reason, purgedBy = null } = {}) {
++ async purgeEntity(h, entityId, { reason, purgedBy = null } = {}) {
if (!reason) throw new TypeError('a purge needs a recorded reason');
-- return db.transaction(() => {
-- db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());
-- return { deleted: db.prepare(`DELETE FROM ${C} WHERE entity_id = ?`).run(entityId).changes };
-- })();
++ assertEntityId(entityId);
++ return h.tx(async (t) => {
++ await lock(t, entityId);
++ await t.exec(sql`INSERT INTO ${p$} (entity_id, reason, purged_by, purged_at) VALUES (${entityId}, ${String(reason)}, ${purgedBy}, ${clock()})
++ ON CONFLICT (entity_id) DO NOTHING`);
++ return { deleted: await t.exec(sql`DELETE FROM ${c$} WHERE entity_id = ${entityId}`) };
++ });
},
};
++
++ const api = bindHandles(db, impl);
++ api.table = C;
++ api.tables = { citations: C, purges: P };
++ api.schema = () => schema(prefix);
++ /** Create the tables where the handle may (tests, PGlite); services put schema() in a migration. */
++ api.ensureSchema = async () => { await db.query(schema(prefix)); return api; };
return api;
}
@@@ -202,4 +293,4 @@ function gateFacts(citations = []) {
};
}
--module.exports = { createCitationStore, gateFacts };
++module.exports = { createCitationStore, schema, gateFacts };
ddiff --git a/lib/discussion.js b/lib/discussion.js
iindex 357002e..e2097b5 100644
---- a/lib/discussion.js
++++ b/lib/discussion.js
@@@ -4,7 +4,7 @@
*
* const { createDiscussionClient, createDiscussionRefs } = require('openvibe-publishing/discussion');
* const community = createDiscussionClient({ communityUrl: 'https://openvibe.community', tokenClient });
-- * const refs = createDiscussionRefs(db, { prefix: 'wiki' }); // wiki_discussion_refs
++ * const refs = createDiscussionRefs(db, { prefix: 'wiki' }); // wiki_discussion_refs (schema('wiki'))
* const { threadId } = await refs.threadFor('pg_1', { service: 'wiki', type: 'page', id: 'pg_1', label: 'Sourdough' }, { client: community });
*
* The thread and its comments live in OpenVibe.Community. This module calls
@@@ -18,7 +18,7 @@
* serviceAuth.createTokenClient). Failures are errors (code 'discussion.unavailable' or the
* problem code Community returned) — never a fabricated thread.
*/
--const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, parseJson } = require('./internal');
++const { PublishingError, assertDb, sqlOf, bindHandles, jsonValue, assertPrefix, clockOf, assertEntityId, parseJson } = require('./internal');
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;
@@@ -85,48 +85,60 @@ function createDiscussionClient({ communityUrl, tokenClient, fetchImpl = globalT
};
}
++/** The DDL for one prefix (idempotent), for the service's migration file. */
++function schema(prefix) {
++ assertPrefix(prefix);
++ const R = `${prefix}_discussion_refs`;
++ return `CREATE TABLE IF NOT EXISTS ${R} (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ thread_id text NOT NULL,
++ ref jsonb NOT NULL,
++ resolved_at bigint NOT NULL
++);
++`;
++}
++
/** Local table of entity → Community thread id. Stores the reference only. */
function createDiscussionRefs(db, { prefix, now } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const R = `${prefix}_discussion_refs`;
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${R} (
-- entity_id TEXT PRIMARY KEY,
-- thread_id TEXT NOT NULL,
-- ref TEXT NOT NULL,
-- resolved_at INTEGER NOT NULL
-- );
-- `);
-- const q = {
-- get: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ?`),
-- put: db.prepare(`INSERT INTO ${R} (entity_id, thread_id, ref, resolved_at) VALUES (?, ?, ?, ?)
-- ON CONFLICT (entity_id) DO UPDATE SET thread_id = excluded.thread_id, ref = excluded.ref, resolved_at = excluded.resolved_at`),
-- del: db.prepare(`DELETE FROM ${R} WHERE entity_id = ?`),
-- };
-- const shape = (row) => (row ? { entityId: row.entity_id, threadId: row.thread_id, ref: parseJson(row.ref, null), resolvedAt: new Date(row.resolved_at).toISOString() } : null);
++ const r$ = sql.ident(R);
++ const shape = (row) => (row ? { entityId: row.entity_id, threadId: row.thread_id, ref: jsonValue(row.ref, null), resolvedAt: new Date(row.resolved_at).toISOString() } : null);
-- const api = {
-- table: R,
-- get(entityId) { return shape(q.get.get(assertEntityId(entityId))); },
++ const impl = {
++ async get(h, entityId) { return shape(await h.maybe(sql`SELECT * FROM ${r$} WHERE entity_id = ${assertEntityId(entityId)}`)); },
/** Record a thread id obtained elsewhere (e.g. from a Community event). */
-- set(entityId, threadId, ref) {
-- q.put.run(assertEntityId(entityId), String(threadId), JSON.stringify(entityRef(ref)), clock());
-- return api.get(entityId);
++ async set(h, entityId, threadId, ref) {
++ assertEntityId(entityId);
++ const r = entityRef(ref);
++ return shape(await h.one(sql`INSERT INTO ${r$} (entity_id, thread_id, ref, resolved_at) VALUES (${entityId}, ${String(threadId)}, ${sql.json(r)}, ${clock()})
++ ON CONFLICT (entity_id) DO UPDATE SET thread_id = excluded.thread_id, ref = excluded.ref, resolved_at = excluded.resolved_at
++ RETURNING *`));
},
-- forget(entityId) { return q.del.run(assertEntityId(entityId)).changes > 0; },
-- /** The stored thread id, or resolve it through Community once and store it. */
-- async threadFor(entityId, ref, { client, ...opts } = {}) {
-- const known = api.get(entityId);
++ async forget(h, entityId) { return (await h.exec(sql`DELETE FROM ${r$} WHERE entity_id = ${assertEntityId(entityId)}`)) > 0; },
++ /**
++ * The stored thread id, or resolve it through Community once and store it. The call to
++ * Community happens between two statements, never inside a transaction this method opens.
++ */
++ async threadFor(h, entityId, ref, { client, ...opts } = {}) {
++ const known = await impl.get(h, entityId);
if (known) return { threadId: known.threadId, created: false, cached: true };
if (!client) throw new TypeError('client (createDiscussionClient) is required to resolve a new thread');
const out = await client.resolveThread(ref, opts);
-- api.set(entityId, out.threadId, ref);
++ await impl.set(h, entityId, out.threadId, ref);
return { ...out, cached: false };
},
};
++
++ const api = bindHandles(db, impl);
++ api.table = R;
++ api.schema = () => schema(prefix);
++ /** Create the table where the handle may (tests, PGlite); services put schema() in a migration. */
++ api.ensureSchema = async () => { await db.query(schema(prefix)); return api; };
return api;
}
--module.exports = { createDiscussionClient, createDiscussionRefs, entityRef };
++module.exports = { createDiscussionClient, createDiscussionRefs, schema, entityRef };
ddiff --git a/lib/index-hooks.js b/lib/index-hooks.js
iindex 2482259..641d970 100644
---- a/lib/index-hooks.js
++++ b/lib/index-hooks.js
@@@ -6,9 +6,9 @@
* const doc = hooks.buildIndexDocument({ owner: 'wiki', type: 'page', id: 'pg_1', revision: 4, state: 'published',
* visibility: 'public', canonicalUrl, title, summary, body, facets: { space: 'food' },
* authorship: rec, citations, decision });
-- * outbox.enqueue(hooks.indexEvent({ document: doc, actor: 'svc:wiki' })); // wiki.index_document.upserted|deleted
++ * await outbox.enqueue(t, hooks.indexEvent({ document: doc, actor: 'svc:wiki' })); // wiki.index_document.upserted|deleted
* const action = hooks.actionFor(before, after); // product domain event, optional
-- * if (action) outbox.enqueue(hooks.publicationEvent({ product: 'wiki', type: 'page', action, id, revision, actor, document: doc, decision }));
++ * if (action) await outbox.enqueue(t, hooks.publicationEvent({ product: 'wiki', type: 'page', action, id, revision, actor, document: doc, decision }));
*
* Documents follow the released contract `search.index-document@1` (openvibe-contracts ≥ 0.12.0,
* contracts/search/index-document.v1.json) exactly: { owner, type, id, revision, deleted,
@@@ -35,7 +35,8 @@
* document is a conflict (first write wins) and a tombstone wins, so the number must grow with
* EVERY change to what is indexed (content, visibility, state, canonical URL, gate decision), not
* only with content revisions. createIndexSequencer(db, { prefix }) keeps that counter in the
-- * product's database: same document → same revision (a safe replay), anything different → +1.
++ * product's database: same document → same revision (a safe replay), anything different → +1;
++ * stamp(t, document) runs in the transaction that enqueues the event (sequencerSchema(prefix) is its DDL).
*
* Events: Search consumes `<owner>.index_document.upserted` (payload = the document) and
* `<owner>.index_document.deleted` (payload = { type, id, revision }), subject { type, id, revision },
@@@ -236,48 +237,72 @@ function buildIndexDocument({
return doc;
}
++/** The index sequencer's DDL for one prefix (idempotent), for the service's migration file. */
++function sequencerSchema(prefix) {
++ require('./internal').assertPrefix(prefix);
++ const T = `${prefix}_index_revisions`;
++ return `CREATE TABLE IF NOT EXISTS ${T} (
++ owner text COLLATE "C" NOT NULL,
++ type text COLLATE "C" NOT NULL,
++ id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ hash text NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (owner, type, id)
++);
++`;
++}
++
/**
* Monotonic index revisions per resource, in <prefix>_index_revisions of the product's database.
* const seq = createIndexSequencer(db, { prefix: 'wiki' });
-- * const doc = seq.stamp(hooks.buildIndexDocument({ ..., revision: 0 })); // revision replaced
++ * await db.tx(async (t) => {
++ * const doc = await seq.stamp(t, hooks.buildIndexDocument({ ..., revision: 0 })); // revision replaced
++ * await outbox.enqueue(t, hooks.indexEvent({ document: doc }));
++ * });
++ * stamp() takes the transaction handle first, always (like the SDK's outbox.enqueue): the stamped
++ * revision must commit if and only if the event that carries it does, or a replay would believe
++ * Search already has a document it never received. Pass `db` itself only when nothing else is written.
*/
function createIndexSequencer(db, { prefix, now } = {}) {
-- const { assertDb, assertPrefix, clockOf, sha256, stableStringify } = require('./internal');
++ const { assertDb, isHandle, sqlOf, assertPrefix, clockOf, sha256, stableStringify } = require('./internal');
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const T = `${prefix}_index_revisions`;
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${T} (
-- owner TEXT NOT NULL,
-- type TEXT NOT NULL,
-- id TEXT NOT NULL,
-- revision INTEGER NOT NULL,
-- hash TEXT NOT NULL,
-- updated_at INTEGER NOT NULL,
-- PRIMARY KEY (owner, type, id)
-- );
-- `);
-- const get = db.prepare(`SELECT revision, hash FROM ${T} WHERE owner = ? AND type = ? AND id = ?`);
-- const put = db.prepare(`INSERT INTO ${T} (owner, type, id, revision, hash, updated_at) VALUES (@owner, @type, @id, @revision, @hash, @now)
-- ON CONFLICT (owner, type, id) DO UPDATE SET revision = excluded.revision, hash = excluded.hash, updated_at = excluded.updated_at`);
-- return {
++ const t$ = sql.ident(T);
++ const api = {
table: T,
-- /** The document with its revision set: unchanged → the last revision, changed → last + 1. */
-- stamp(document) {
++ schema: () => sequencerSchema(prefix),
++ /** Create the table where the handle may (tests, PGlite); services put schema() in a migration. */
++ async ensureSchema() { await db.query(sequencerSchema(prefix)); return api; },
++ /**
++ * The document with its revision set: unchanged → the last revision, changed → last + 1.
++ * One upsert (plus a read when nothing changed), safe against concurrent stamps of one resource.
++ */
++ async stamp(t, document) {
++ if (!isHandle(t)) throw new TypeError('stamp(t, document): pass the transaction handle db.tx gives you (or db)');
checkIdentity(document);
const { revision: _ignored, ...rest } = document;
const hash = sha256(stableStringify(rest));
-- return db.transaction(() => {
-- const cur = get.get(document.owner, document.type, document.id);
-- if (cur && cur.hash === hash) return { ...document, revision: cur.revision };
-- const revision = Math.max(cur ? cur.revision + 1 : 1, document.revision);
-- put.run({ owner: document.owner, type: document.type, id: document.id, revision, hash, now: clock() });
-- return { ...document, revision };
-- })();
++ const first = Math.min(2147483647, Math.max(1, document.revision));
++ const row = await t.maybe(sql`INSERT INTO ${t$} AS s (owner, type, id, revision, hash, updated_at)
++ VALUES (${document.owner}, ${document.type}, ${document.id}, ${first}, ${hash}, ${clock()})
++ ON CONFLICT (owner, type, id) DO UPDATE SET revision = GREATEST(s.revision + 1, excluded.revision), hash = excluded.hash, updated_at = excluded.updated_at
++ WHERE s.hash <> excluded.hash
++ RETURNING s.revision`);
++ if (row) return { ...document, revision: row.revision };
++ const cur = await t.one(sql`SELECT revision FROM ${t$} WHERE owner = ${document.owner} AND type = ${document.type} AND id = ${document.id}`);
++ return { ...document, revision: cur.revision };
++ },
++ async current(...args) {
++ const h = isHandle(args[0]) ? args.shift() : db;
++ const [owner, type, id] = args;
++ return h.value(sql`SELECT revision FROM ${t$} WHERE owner = ${String(owner)} AND type = ${String(type)} AND id = ${String(id)}`);
},
-- current(owner, type, id) { const r = get.get(owner, type, id); return r ? r.revision : null; },
};
++ return api;
}
/** 'usr_…' | 'svc:wiki' | 'service:wiki' | { type, id } → SubjectRef. */
@@@ -375,5 +400,5 @@ function publicationEvent({ product, type, action, id, revision, actor, document
module.exports = {
SCHEMA, ACTIONS, AUTHORSHIP, SEARCH_REASONS, LIMITS,
-- buildIndexDocument, tombstone, searchIndexability, createIndexSequencer, buildProvenance, indexEvent, actionFor, publicationEvent, subjectRef,
++ buildIndexDocument, tombstone, searchIndexability, createIndexSequencer, sequencerSchema, buildProvenance, indexEvent, actionFor, publicationEvent, subjectRef,
};
ddiff --git a/lib/internal.js b/lib/internal.js
iindex 1d549e2..2bac451 100644
---- a/lib/internal.js
++++ b/lib/internal.js
@@@ -2,8 +2,19 @@
/**
* Helpers shared by the modules in this package. Not a public entry point.
*
-- * Every storage module works on a better-sqlite3 handle the consumer owns and names its tables
-- * with the consumer's prefix, so a product's publication state stays in the product's database.
++ * Every storage module works on an openvibe-sdk/db handle the consumer owns (PostgreSQL through
++ * PgBouncer in production, PGlite in tests; ADR-035) and names its tables with the consumer's
++ * prefix, so a product's publication state stays in the product's database.
++ *
++ * Handles: every data method of a store takes an optional transaction handle as its first argument.
++ * await revisions.create({ … }) // runs on the store's db
++ * await db.tx(async (t) => { // one transaction with the caller's own writes
++ * await t.exec(sql`UPDATE pages SET … WHERE id = ${id}`);
++ * await revisions.create(t, { … });
++ * });
++ * A store write that needs several statements runs them in db.tx() on its own, or in a savepoint of the
++ * caller's transaction (t.tx), so a store call is all or nothing either way and a caller can catch its
++ * error (a 412 conflict, say) and still commit the rest.
*/
const crypto = require('crypto');
@@@ -21,10 +32,86 @@ class PublishingError extends Error {
}
}
++/** An openvibe-sdk/db handle: the pool (createDb) or a transaction/savepoint handle (db.tx gives it). */
++function isHandle(x) {
++ return x != null && typeof x === 'object' && typeof x.query === 'function' && typeof x.tx === 'function';
++}
++
function assertDb(db) {
-- if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') {
-- throw new TypeError('a better-sqlite3 database handle is required');
++ if (db && typeof db.prepare === 'function' && typeof db.query !== 'function') {
++ throw new TypeError('openvibe-publishing 1.x takes an openvibe-sdk/db handle (createDb); better-sqlite3 handles are 0.4.x');
++ }
++ if (!isHandle(db) || typeof db.many !== 'function' || typeof db.maybe !== 'function' || typeof db.exec !== 'function') {
++ throw new TypeError('an openvibe-sdk/db handle is required (createDb from openvibe-sdk/db)');
++ }
++}
++
++/**
++ * The sql tag of the handle's own openvibe-sdk copy: fragments are recognised by the handle that built
++ * them, so a store never mixes two copies of the SDK.
++ */
++function sqlOf(db) {
++ if (typeof db.sql === 'function' && typeof db.sql.ident === 'function') return db.sql;
++ return require('openvibe-sdk/db').sql;
++}
++
++/**
++ * Public methods from implementations whose first parameter is the handle to run on:
++ * api.m(...args) runs on `db`; api.m(t, ...args) runs inside the caller's transaction `t`.
++ */
++function bindHandles(db, impl) {
++ const api = {};
++ for (const [name, fn] of Object.entries(impl)) {
++ api[name] = (...args) => (isHandle(args[0]) ? fn(...args) : fn(db, ...args));
}
++ return api;
++}
++
++/** Serialises writers of one key until the transaction ends (pg_advisory_xact_lock: fine behind PgBouncer). */
++function lockKey(t, sql, key) {
++ return t.query(sql`SELECT pg_advisory_xact_lock(hashtextextended(${String(key)}, 0))`);
++}
++
++/** A bounded list size: an integer in [1, max], `fallback` when missing or not a number. */
++function boundedLimit(limit, fallback = 1000, max = 10000) {
++ const n = Math.floor(Number(limit));
++ if (!Number.isFinite(n)) return fallback;
++ return Math.max(1, Math.min(max, n));
++}
++
++/** A positive safe integer id, or null (a lookup by a malformed id finds nothing instead of erroring). */
++function toId(v) {
++ const n = typeof v === 'number' ? v : Number(v);
++ return Number.isSafeInteger(n) && n > 0 ? n : null;
++}
++
++/** An integer that fits a PostgreSQL integer column, or null. */
++function toInt4(v) {
++ const n = typeof v === 'number' ? v : Number(v);
++ return Number.isInteger(n) && n >= -2147483648 && n <= 2147483647 ? n : null;
++}
++
++/**
++ * Validated (entityId, number) references for the batch reads (getMany, forRevisions, latestMany):
++ * at most `max`, each with a string entity id; a number that cannot exist maps to null.
++ */
++function revisionRefs(refs, max = 1000) {
++ if (!Array.isArray(refs)) throw new TypeError('refs must be an array of { entityId, revision }');
++ if (refs.length > max) throw new TypeError(`at most ${max} refs per call`);
++ return refs.map((r) => {
++ if (!r || typeof r.entityId !== 'string' || !r.entityId) throw new TypeError('each ref needs an entityId');
++ const n = toInt4(r.revision != null ? r.revision : r.number);
++ return { entityId: r.entityId, number: n != null && n >= 0 ? n : null };
++ });
++}
++
++const refKey = (entityId, number) => `${entityId}\u0000${number}`;
++
++/** jsonb comes back parsed; tolerate text (a column read through another path) and null. */
++function jsonValue(v, fallback = null) {
++ if (v == null) return fallback;
++ if (typeof v === 'string') return parseJson(v, fallback);
++ return v;
}
function assertPrefix(prefix) {
@@@ -91,20 +178,33 @@ function isoOrNull(value, name) {
}
/**
-- * Append-only protection for a table: UPDATE always aborts, DELETE aborts unless the entity was
-- * first registered in `<purgeTable>` (an audited, explicit erasure path).
++ * Append-only protection for a table, in PL/pgSQL: UPDATE always aborts; DELETE aborts unless the
++ * entity was first registered in `purgeTable` (an audited, explicit erasure path). Without a purge
++ * table, only UPDATE is refused. SQLSTATE 23001 (restrict_violation). TRUNCATE is not a row operation:
++ * the runtime role behind PgBouncer has no TRUNCATE privilege (ADR-035), and the owner's one-time
++ * import (importSqlite truncate) needs it.
*/
--function immutableTriggers(table, purgeTable, keyColumn = 'entity_id') {
-- return `
-- CREATE TRIGGER IF NOT EXISTS ${table}_no_update BEFORE UPDATE ON ${table}
-- BEGIN SELECT RAISE(ABORT, '${table} rows are immutable'); END;
-- CREATE TRIGGER IF NOT EXISTS ${table}_no_delete BEFORE DELETE ON ${table}
-- WHEN NOT EXISTS (SELECT 1 FROM ${purgeTable} WHERE entity_id = OLD.${keyColumn})
-- BEGIN SELECT RAISE(ABORT, '${table} rows are never deleted outside a recorded purge'); END;
-- `;
++function immutableTriggers(table, purgeTable = null, keyColumn = 'entity_id') {
++ const deleteRule = purgeTable
++ ? `IF NOT EXISTS (SELECT 1 FROM ${purgeTable} WHERE entity_id = OLD.${keyColumn}) THEN
++ RAISE EXCEPTION '${table} rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ `
++ : '';
++ return `CREATE OR REPLACE FUNCTION ${table}_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION '${table} rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ ${deleteRule}RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER ${table}_no_update BEFORE UPDATE ON ${table} FOR EACH ROW EXECUTE FUNCTION ${table}_guard();
++${purgeTable ? `CREATE OR REPLACE TRIGGER ${table}_no_delete BEFORE DELETE ON ${table} FOR EACH ROW EXECUTE FUNCTION ${table}_guard();\n` : ''}`;
}
module.exports = {
-- PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,
++ PublishingError, assertDb, isHandle, sqlOf, bindHandles, lockKey, boundedLimit, toId, toInt4, jsonValue, revisionRefs, refKey,
++ assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,
assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers, PREFIX_RE,
};
ddiff --git a/lib/media.js b/lib/media.js
iindex f581f6f..d210717 100644
---- a/lib/media.js
++++ b/lib/media.js
@@@ -3,12 +3,12 @@
* openvibe-publishing/media — attachments by OpenVibe.Media object id, with an explicit
* broken-asset state.
*
-- * const { createAttachmentStore } = require('openvibe-publishing/media');
++ * const { createAttachmentStore, schema } = require('openvibe-publishing/media');
* const media = createAttachmentStore(db, { prefix: 'blog_post' }); // blog_post_attachments
-- * media.attach({ entityId: 'post_1', mediaId: 'med_01J…', role: 'cover', alt: 'A loaf' });
++ * await media.attach({ entityId: 'post_1', mediaId: 'med_01J…', role: 'cover', alt: 'A loaf' });
* // Media says the object is gone (media.object.deleted event, or a 404 on fetch):
-- * media.markBroken('med_01J…', 'deleted');
-- * media.list('post_1') // → [{ …, state: 'broken', brokenReason: 'deleted' }]
++ * await media.markBroken('med_01J…', 'deleted');
++ * await media.list('post_1') // → [{ …, state: 'broken', brokenReason: 'deleted' }]
*
* Bytes never live here: a row is a reference (MediaRef: med_<ULID> or the transitional
* legacy:<app>:<kind>:<id>). States:
@@@ -19,7 +19,9 @@
* A check that fails for another reason (timeout, 5xx) changes nothing and is reported as
* check_failed: an outage is not evidence the object is gone, nor that it exists.
*/
--const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
++const {
++ PublishingError, assertDb, sqlOf, bindHandles, boundedLimit, toId, toInt4, assertPrefix, clockOf, assertEntityId,
++} = require('./internal');
const MEDIA_ID_RE = /^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$/;
const ROLE_RE = /^[a-z][a-z0-9_]{1,39}$/;
@@@ -48,121 +50,144 @@ function shape(row) {
};
}
++/** Distinct entity ids, sorted (the answer v0.4 gave from SELECT DISTINCT … ORDER BY entity_id). */
++function distinctSorted(rows) {
++ return [...new Set(rows.map((r) => r.entity_id))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
++}
++
++/** The DDL for one prefix (idempotent), for the service's migration file. */
++function schema(prefix) {
++ assertPrefix(prefix);
++ const A = `${prefix}_attachments`;
++ return `CREATE TABLE IF NOT EXISTS ${A} (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer,
++ media_id text COLLATE "C" NOT NULL,
++ role text NOT NULL DEFAULT 'inline',
++ variant text,
++ alt text,
++ caption text,
++ position integer NOT NULL DEFAULT 0,
++ state text NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),
++ broken_reason text CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),
++ checked_at bigint,
++ created_at bigint NOT NULL,
++ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS ${A}_entity ON ${A} (entity_id, position, id);
++CREATE INDEX IF NOT EXISTS ${A}_media ON ${A} (media_id, entity_id);
++`;
++}
++
function createAttachmentStore(db, { prefix, now } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const A = `${prefix}_attachments`;
++ const a$ = sql.ident(A);
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${A} (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- entity_id TEXT NOT NULL,
-- revision INTEGER,
-- media_id TEXT NOT NULL,
-- role TEXT NOT NULL DEFAULT 'inline',
-- variant TEXT,
-- alt TEXT,
-- caption TEXT,
-- position INTEGER NOT NULL DEFAULT 0,
-- state TEXT NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),
-- broken_reason TEXT CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),
-- checked_at INTEGER,
-- created_at INTEGER NOT NULL,
-- CHECK ((state = 'broken') = (broken_reason IS NOT NULL))
-- );
-- CREATE INDEX IF NOT EXISTS ${A}_entity ON ${A} (entity_id, position);
-- CREATE INDEX IF NOT EXISTS ${A}_media ON ${A} (media_id);
-- `);
--
-- const q = {
-- insert: db.prepare(`INSERT INTO ${A} (entity_id, revision, media_id, role, variant, alt, caption, position, created_at)
-- VALUES (@entity_id, @revision, @media_id, @role, @variant, @alt, @caption, @position, @now)`),
-- byId: db.prepare(`SELECT * FROM ${A} WHERE id = ?`),
-- list: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? ORDER BY position, id`),
-- listRev: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND (revision IS NULL OR revision = ?) ORDER BY position, id`),
-- detach: db.prepare(`DELETE FROM ${A} WHERE id = ? AND entity_id = ?`),
-- broken: db.prepare(`UPDATE ${A} SET state = 'broken', broken_reason = ?, checked_at = ? WHERE media_id = ?`),
-- available: db.prepare(`UPDATE ${A} SET state = 'available', broken_reason = NULL, checked_at = ? WHERE media_id = ?`),
-- brokenFor: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND state = 'broken' ORDER BY position, id`),
-- entitiesWith: db.prepare(`SELECT DISTINCT entity_id FROM ${A} WHERE media_id = ? ORDER BY entity_id`),
-- };
--
-- const api = {
-- table: A,
--
-- attach({ entityId, mediaId, revision = null, role = 'inline', variant = null, alt = null, caption = null, position = 0 } = {}) {
++ const impl = {
++ async attach(h, { entityId, mediaId, revision = null, role = 'inline', variant = null, alt = null, caption = null, position = 0 } = {}) {
assertEntityId(entityId);
if (!isMediaId(mediaId)) throw new PublishingError(400, 'media.invalid_id', 'mediaId must be a Media object id (med_<ULID> or legacy:<app>:<kind>:<id>)');
if (!ROLE_RE.test(role)) throw new TypeError(`role must match ${ROLE_RE}`);
if (variant != null && !VARIANT_RE.test(variant)) throw new TypeError(`variant must match ${VARIANT_RE}`);
-- if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');
-- const info = q.insert.run({
-- entity_id: entityId, revision, media_id: mediaId, role, variant,
-- alt: alt == null ? null : String(alt).slice(0, 1000),
-- caption: caption == null ? null : String(caption).slice(0, 2000),
-- position: Number.isInteger(position) ? position : 0, now: clock(),
-- });
-- return shape(q.byId.get(info.lastInsertRowid));
++ if (revision != null && (!Number.isInteger(revision) || revision < 1 || revision > 2147483647)) throw new TypeError('revision must be a positive integer');
++ const pos = toInt4(position);
++ return shape(await h.one(sql`INSERT INTO ${a$} (entity_id, revision, media_id, role, variant, alt, caption, position, created_at)
++ VALUES (${entityId}, ${revision}, ${mediaId}, ${role}, ${variant}, ${alt == null ? null : String(alt).slice(0, 1000)},
++ ${caption == null ? null : String(caption).slice(0, 2000)}, ${Number.isInteger(position) && pos != null ? pos : 0}, ${clock()})
++ RETURNING *`));
},
-- detach(entityId, attachmentId) { return q.detach.run(Number(attachmentId), assertEntityId(entityId)).changes > 0; },
++ async detach(h, entityId, attachmentId) {
++ const id = toId(attachmentId);
++ assertEntityId(entityId);
++ if (id == null) return false;
++ return (await h.exec(sql`DELETE FROM ${a$} WHERE id = ${id} AND entity_id = ${entityId}`)) > 0;
++ },
-- get(id) { return shape(q.byId.get(Number(id))); },
++ async get(h, id) {
++ const n = toId(id);
++ return n == null ? null : shape(await h.maybe(sql`SELECT * FROM ${a$} WHERE id = ${n}`));
++ },
/** All attachments of an entity; with `revision`, entity-wide ones plus that revision's. */
-- list(entityId, { revision } = {}) {
++ async list(h, entityId, { revision, limit = 1000 } = {}) {
assertEntityId(entityId);
-- return (revision == null ? q.list.all(entityId) : q.listRev.all(entityId, revision)).map(shape);
++ const rev = revision == null ? null : toInt4(revision);
++ const cond = revision == null ? sql`` : sql` AND (revision IS NULL OR revision = ${rev}::integer)`;
++ return (await h.many(sql`SELECT * FROM ${a$} WHERE entity_id = ${entityId}${cond} ORDER BY position, id LIMIT ${boundedLimit(limit)}`)).map(shape);
},
-- broken(entityId) { return q.brokenFor.all(assertEntityId(entityId)).map(shape); },
++ async broken(h, entityId, { limit = 1000 } = {}) {
++ return (await h.many(sql`SELECT * FROM ${a$} WHERE entity_id = ${assertEntityId(entityId)} AND state = 'broken'
++ ORDER BY position, id LIMIT ${boundedLimit(limit)}`)).map(shape);
++ },
-- /** Entities that reference a Media object — what to re-render/re-index when it changes. */
-- entitiesUsing(mediaId) { return q.entitiesWith.all(String(mediaId)).map((r) => r.entity_id); },
++ /**
++ * Entities that reference a Media object — what to re-render/re-index when it changes.
++ * Keyset pages: `after` is the last entity id of the previous page.
++ */
++ async entitiesUsing(h, mediaId, { limit = 1000, after = null } = {}) {
++ const cursor = after == null ? sql`` : sql` AND entity_id > ${String(after)}`;
++ return (await h.many(sql`SELECT DISTINCT entity_id FROM ${a$} WHERE media_id = ${String(mediaId)}${cursor}
++ ORDER BY entity_id LIMIT ${boundedLimit(limit)}`)).map((r) => r.entity_id);
++ },
-- /** Mark every attachment of this object broken. Returns the affected entity ids. */
-- markBroken(mediaId, reason = 'not_found') {
++ /** Mark every attachment of this object broken, in one statement. Returns the affected entity ids. */
++ async markBroken(h, mediaId, reason = 'not_found') {
if (!BROKEN_REASONS.includes(reason)) throw new TypeError(`reason must be one of ${BROKEN_REASONS.join(', ')}`);
-- return db.transaction(() => {
-- const ids = api.entitiesUsing(mediaId);
-- q.broken.run(reason, clock(), String(mediaId));
-- return ids;
-- })();
++ return distinctSorted(await h.many(sql`UPDATE ${a$} SET state = 'broken', broken_reason = ${reason}, checked_at = ${clock()}
++ WHERE media_id = ${String(mediaId)} RETURNING entity_id`));
},
-- markAvailable(mediaId) {
-- return db.transaction(() => {
-- const ids = api.entitiesUsing(mediaId);
-- q.available.run(clock(), String(mediaId));
-- return ids;
-- })();
++ async markAvailable(h, mediaId) {
++ return distinctSorted(await h.many(sql`UPDATE ${a$} SET state = 'available', broken_reason = NULL, checked_at = ${clock()}
++ WHERE media_id = ${String(mediaId)} RETURNING entity_id`));
},
/**
* Check an entity's attachments against Media. resolve(mediaId) must return
* { exists: true } | { exists: false, reason: 'not_found'|'deleted'|'forbidden' } or throw.
* Returns [{ mediaId, outcome: 'available'|'broken'|'check_failed', reason? }].
++ * The verdicts are written together in one statement after every check has answered; no
++ * transaction is held open across the calls to Media.
*/
-- async verify(entityId, { resolve } = {}) {
++ async verify(h, entityId, { resolve } = {}) {
if (typeof resolve !== 'function') throw new TypeError('resolve(mediaId) is required');
-- const ids = [...new Set(api.list(entityId).map((a) => a.mediaId))];
++ assertEntityId(entityId);
++ const ids = [...new Set((await h.many(sql`SELECT media_id FROM ${a$} WHERE entity_id = ${entityId} ORDER BY position, id LIMIT 10000`)).map((r) => r.media_id))];
const out = [];
++ const verdicts = [];
for (const mediaId of ids) {
let r;
try { r = await resolve(mediaId); } catch (err) {
out.push({ mediaId, outcome: 'check_failed', error: String(err && err.message || err) });
continue;
}
-- if (r && r.exists === true) { api.markAvailable(mediaId); out.push({ mediaId, outcome: 'available' }); }
++ if (r && r.exists === true) { verdicts.push([mediaId, 'available', null]); out.push({ mediaId, outcome: 'available' }); }
else if (r && r.exists === false && BROKEN_REASONS.includes(r.reason || 'not_found')) {
-- api.markBroken(mediaId, r.reason || 'not_found');
++ verdicts.push([mediaId, 'broken', r.reason || 'not_found']);
out.push({ mediaId, outcome: 'broken', reason: r.reason || 'not_found' });
} else out.push({ mediaId, outcome: 'check_failed', error: 'resolver returned no verdict' });
}
++ if (verdicts.length) {
++ await h.exec(sql`UPDATE ${a$} AS a SET state = v.state, broken_reason = v.reason, checked_at = ${clock()}::bigint
++ FROM unnest(${verdicts.map((v) => v[0])}::text[], ${verdicts.map((v) => v[1])}::text[], ${verdicts.map((v) => v[2])}::text[]) AS v(media_id, state, reason)
++ WHERE a.media_id = v.media_id`);
++ }
return out;
},
};
++
++ const api = bindHandles(db, impl);
++ api.table = A;
++ api.schema = () => schema(prefix);
++ /** Create the table where the handle may (tests, PGlite); services put schema() in a migration. */
++ api.ensureSchema = async () => { await db.query(schema(prefix)); return api; };
return api;
}
@@@ -189,4 +214,4 @@ function figureHtml(att, { urlFor, unavailableText = 'This media is no longer av
return `<figure class="ov-media" data-media-id="${esc(att.mediaId)}" data-state="${esc(att.state)}"><img src="${esc(src)}" alt="${esc(att.alt || '')}" loading="lazy" decoding="async">${caption}</figure>`;
}
--module.exports = { createAttachmentStore, mediaRef, figureHtml, isMediaId, STATES, BROKEN_REASONS, MEDIA_ID_RE };
++module.exports = { createAttachmentStore, schema, mediaRef, figureHtml, isMediaId, STATES, BROKEN_REASONS, MEDIA_ID_RE };
ddiff --git a/lib/revisions.js b/lib/revisions.js
iindex b854d0d..69942a6 100644
---- a/lib/revisions.js
++++ b/lib/revisions.js
@@@ -2,29 +2,32 @@
/**
* openvibe-publishing/revisions — drafts and immutable revisions in the consumer's own database.
*
-- * const { createRevisionStore } = require('openvibe-publishing/revisions');
-- * const revs = createRevisionStore(db, { prefix: 'wiki_page' }); // wiki_page_revisions, …
-- * const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: '# Hi', fields: { title: 'Hi' }, author: 'user:usr_…' });
-- * revs.create({ entityId: 'pg_1', expectedRevision: 1, content: '# Hello', author: … }); // r2, parent r1
-- * revs.diff('pg_1', 1, 2, { mode: 'word' });
-- * revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: … }); // r3 = copy of r1
++ * const { createRevisionStore, schema } = require('openvibe-publishing/revisions');
++ * const revs = createRevisionStore(db, { prefix: 'wiki_page' }); // db: openvibe-sdk/db handle
++ * // schema('wiki_page') → the DDL for the service's migration (tests: await revs.ensureSchema())
++ * const r1 = await revs.create({ entityId: 'pg_1', expectedRevision: 0, content: '# Hi', fields: { title: 'Hi' }, author: 'user:usr_…' });
++ * await revs.create({ entityId: 'pg_1', expectedRevision: 1, content: '# Hello', author: … }); // r2, parent r1
++ * await revs.diff('pg_1', 1, 2, { mode: 'word' });
++ * await revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: … }); // r3 = copy of r1
++ * await db.tx(async (t) => { await t.exec(…); await revs.create(t, { … }); }); // one transaction
*
-- * Tables (created idempotently):
-- * <prefix>_revisions immutable: UPDATE aborts; DELETE aborts unless the entity is purged
++ * Tables:
++ * <prefix>_revisions immutable: UPDATE aborts; DELETE aborts unless the entity is purged (PL/pgSQL trigger)
* <prefix>_drafts one mutable working copy per (entity, owner), based on a revision
* <prefix>_revision_purges audit rows for explicit erasure (purgeEntity)
*
* Optimistic concurrency: every write names the revision number it was based on
* (`expectedRevision`, 0 for a new entity). If the head moved, the write throws a PublishingError
-- * with status 412 and code 'revision.conflict' carrying `expected` and `current`. A UNIQUE
-- * (entity_id, number) index is the last line of defence against two processes racing.
++ * with status 412 and code 'revision.conflict' carrying `expected` and `current`. Writers of one
++ * entity take a transaction-scoped advisory lock, so a check and its insert never interleave with
++ * another writer or a purge; the UNIQUE (entity_id, number) index stays the last line of defence.
*
* This module stores content, not publication state: which revision is live is the product's
* decision, kept in the product's own tables.
*/
const {
-- PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,
-- assertEntityId, assertRevisionNumber, immutableTriggers,
++ PublishingError, assertDb, sqlOf, bindHandles, lockKey, boundedLimit, toInt4, jsonValue, revisionRefs, refKey, assertPrefix, clockOf,
++ newId, sha256, stableStringify, assertEntityId, assertRevisionNumber, immutableTriggers,
} = require('./internal');
const { diffText } = require('./diff');
@@@ -37,7 +40,7 @@ function conflict(entityId, expected, current) {
}
function shape(row) {
-- if (!row) return null;
++ if (!row || row.id == null) return null;
return {
id: row.id,
entityId: row.entity_id,
@@@ -47,8 +50,8 @@ function shape(row) {
kind: row.kind,
revertedTo: row.reverted_to,
content: row.content,
-- fields: parseJson(row.fields, {}),
-- meta: parseJson(row.meta, {}),
++ fields: jsonValue(row.fields, {}),
++ meta: jsonValue(row.meta, {}),
contentHash: row.content_hash,
author: row.author,
message: row.message,
@@@ -56,181 +59,223 @@ function shape(row) {
};
}
++function draftShape(row) {
++ if (!row) return null;
++ return {
++ entityId: row.entity_id, owner: row.owner, baseRevision: row.base_revision, content: row.content,
++ fields: jsonValue(row.fields, {}), meta: jsonValue(row.meta, {}),
++ createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
++ stale: row.base_revision !== row.head_number,
++ };
++}
++
++/** The DDL for one prefix (idempotent), for the service's migration file. */
++function schema(prefix) {
++ assertPrefix(prefix);
++ const T = `${prefix}_revisions`;
++ const D = `${prefix}_drafts`;
++ const P = `${prefix}_revision_purges`;
++ return `CREATE TABLE IF NOT EXISTS ${T} (
++ id text PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ number integer NOT NULL CHECK (number >= 1),
++ parent_id text,
++ parent_number integer,
++ kind text NOT NULL CHECK (kind IN ('edit','revert','import')),
++ reverted_to integer,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ content_hash text NOT NULL,
++ author text,
++ message text,
++ created_at bigint NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS ${T}_entity_num ON ${T} (entity_id, number);
++CREATE TABLE IF NOT EXISTS ${D} (
++ entity_id text COLLATE "C" NOT NULL,
++ owner text COLLATE "C" NOT NULL,
++ base_revision integer NOT NULL,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (entity_id, owner)
++);
++CREATE INDEX IF NOT EXISTS ${D}_updated ON ${D} (entity_id, updated_at DESC, owner);
++CREATE TABLE IF NOT EXISTS ${P} (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++${immutableTriggers(T, P)}`;
++}
++
function createRevisionStore(db, { prefix, now } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const T = `${prefix}_revisions`;
const D = `${prefix}_drafts`;
const P = `${prefix}_revision_purges`;
++ const t$ = sql.ident(T);
++ const d$ = sql.ident(D);
++ const p$ = sql.ident(P);
++ const headNum = (entity) => sql`(SELECT COALESCE(max(number), 0) FROM ${t$} WHERE entity_id = ${entity})`;
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${T} (
-- id TEXT PRIMARY KEY,
-- entity_id TEXT NOT NULL,
-- number INTEGER NOT NULL CHECK (number >= 1),
-- parent_id TEXT,
-- parent_number INTEGER,
-- kind TEXT NOT NULL CHECK (kind IN ('edit','revert','import')),
-- reverted_to INTEGER,
-- content TEXT NOT NULL,
-- fields TEXT NOT NULL DEFAULT '{}',
-- meta TEXT NOT NULL DEFAULT '{}',
-- content_hash TEXT NOT NULL,
-- author TEXT,
-- message TEXT,
-- created_at INTEGER NOT NULL
-- );
-- CREATE UNIQUE INDEX IF NOT EXISTS ${T}_entity_number ON ${T} (entity_id, number);
-- CREATE TABLE IF NOT EXISTS ${D} (
-- entity_id TEXT NOT NULL,
-- owner TEXT NOT NULL,
-- base_revision INTEGER NOT NULL,
-- content TEXT NOT NULL,
-- fields TEXT NOT NULL DEFAULT '{}',
-- meta TEXT NOT NULL DEFAULT '{}',
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- PRIMARY KEY (entity_id, owner)
-- );
-- CREATE TABLE IF NOT EXISTS ${P} (
-- entity_id TEXT PRIMARY KEY,
-- reason TEXT NOT NULL,
-- purged_by TEXT,
-- purged_at INTEGER NOT NULL
-- );
-- ${immutableTriggers(T, P)}
-- `);
--
-- const q = {
-- head: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? ORDER BY number DESC LIMIT 1`),
-- byNumber: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number = ?`),
-- byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),
-- list: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number < ? ORDER BY number DESC LIMIT ?`),
-- insert: db.prepare(`INSERT INTO ${T} (id, entity_id, number, parent_id, parent_number, kind, reverted_to, content, fields, meta, content_hash, author, message, created_at)
-- VALUES (@id, @entity_id, @number, @parent_id, @parent_number, @kind, @reverted_to, @content, @fields, @meta, @content_hash, @author, @message, @created_at)`),
-- draft: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? AND owner = ?`),
-- draftsFor: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? ORDER BY updated_at DESC`),
-- upsertDraft: db.prepare(`INSERT INTO ${D} (entity_id, owner, base_revision, content, fields, meta, created_at, updated_at)
-- VALUES (@entity_id, @owner, @base_revision, @content, @fields, @meta, @now, @now)
-- ON CONFLICT (entity_id, owner) DO UPDATE SET base_revision = excluded.base_revision, content = excluded.content,
-- fields = excluded.fields, meta = excluded.meta, updated_at = excluded.updated_at`),
-- deleteDraft: db.prepare(`DELETE FROM ${D} WHERE entity_id = ? AND owner = ?`),
-- purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),
-- };
++ function contentHash(content, fields) {
++ return sha256(`${content}\u0000${stableStringify(fields || {})}`);
++ }
-- function headNumber(entityId) {
-- const h = q.head.get(entityId);
-- return h ? h.number : 0;
++ async function headNumber(h, entityId) {
++ return h.value(sql`SELECT ${headNum(entityId)} AS n`);
}
-- function contentHash(content, fields) {
-- return sha256(`${content}\u0000${stableStringify(fields || {})}`);
++ async function byNumber(h, entityId, number) {
++ const n = toInt4(number);
++ if (n == null || n < 1) return null;
++ return h.maybe(sql`SELECT * FROM ${t$} WHERE entity_id = ${entityId} AND number = ${n}`);
}
-- function insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, revertedTo, allowUnchanged }) {
++ function checkWrite({ entityId, expectedRevision, content, fields, meta, kind }) {
assertEntityId(entityId);
assertRevisionNumber(expectedRevision, 'expectedRevision');
if (typeof content !== 'string') throw new TypeError('content must be a string');
if (fields != null && (typeof fields !== 'object' || Array.isArray(fields))) throw new TypeError('fields must be an object');
-- if (meta != null && (typeof meta !== 'object' || Array.isArray(meta))) throw new TypeError('meta must be an object');
++ if (meta != null && typeof meta !== 'function' && (typeof meta !== 'object' || Array.isArray(meta))) throw new TypeError('meta must be an object');
if (!KINDS.includes(kind)) throw new TypeError(`kind must be one of ${KINDS.join(', ')}`);
-- if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged; its id cannot be reused`);
-- const head = q.head.get(entityId);
++ }
++
++ /** Inside a transaction holding the entity's lock. `meta` may be a function of the current head number. */
++ async function insertRevision(t, { entityId, expectedRevision, content, fields, meta, author, message, kind, revertedTo, allowUnchanged }) {
++ const cur = await t.one(sql`SELECT EXISTS (SELECT 1 FROM ${p$} WHERE entity_id = ${entityId}) AS purged, h.*
++ FROM (SELECT 1) AS one LEFT JOIN LATERAL (SELECT * FROM ${t$} WHERE entity_id = ${entityId} ORDER BY number DESC LIMIT 1) AS h ON true`);
++ if (cur.purged) throw new PublishingError(410, 'entity.purged', `${entityId} was purged; its id cannot be reused`);
++ const head = cur.id == null ? null : cur;
const current = head ? head.number : 0;
if (current !== expectedRevision) throw conflict(entityId, expectedRevision, current);
const hash = contentHash(content, fields);
if (head && head.content_hash === hash && !allowUnchanged) return { revision: shape(head), created: false };
-- const t = clock();
-- const row = {
-- id: newId('rev', t),
-- entity_id: entityId,
-- number: current + 1,
-- parent_id: head ? head.id : null,
-- parent_number: head ? head.number : null,
-- kind,
-- reverted_to: revertedTo == null ? null : revertedTo,
-- content,
-- fields: JSON.stringify(fields || {}),
-- meta: JSON.stringify(meta || {}),
-- content_hash: hash,
-- author: author == null ? null : String(author),
-- message: message == null ? null : String(message).slice(0, 2000),
-- created_at: t,
-- };
-- try {
-- q.insert.run(row);
-- } catch (err) {
-- if (/UNIQUE constraint failed/.test(String(err && err.message))) throw conflict(entityId, expectedRevision, headNumber(entityId));
-- throw err;
-- }
-- return { revision: shape(q.byId.get(row.id)), created: true };
++ const at = clock();
++ const m = typeof meta === 'function' ? meta(current) : meta;
++ const row = await t.maybe(sql`INSERT INTO ${t$} (id, entity_id, number, parent_id, parent_number, kind, reverted_to, content, fields, meta, content_hash, author, message, created_at)
++ VALUES (${newId('rev', at)}, ${entityId}, ${current + 1}, ${head ? head.id : null}, ${head ? head.number : null}, ${kind},
++ ${revertedTo == null ? null : revertedTo}, ${content}, ${sql.json(fields || {})}, ${sql.json(m || {})}, ${hash},
++ ${author == null ? null : String(author)}, ${message == null ? null : String(message).slice(0, 2000)}, ${at})
++ ON CONFLICT (entity_id, number) DO NOTHING
++ RETURNING *`);
++ if (!row) throw conflict(entityId, expectedRevision, await headNumber(t, entityId));
++ return { revision: shape(row), created: true };
}
-- const tx = (fn) => db.transaction(fn);
--
-- const store = {
-- prefix,
-- tables: { revisions: T, drafts: D, purges: P },
++ const lock = (t, entityId) => lockKey(t, sql, `${T}:${entityId}`);
++ const impl = {
/** Latest revision of an entity, or null. */
-- head(entityId) { return shape(q.head.get(assertEntityId(entityId))); },
++ async head(h, entityId) {
++ return shape(await h.maybe(sql`SELECT * FROM ${t$} WHERE entity_id = ${assertEntityId(entityId)} ORDER BY number DESC LIMIT 1`));
++ },
/** Current head number (0 when the entity has no revision yet). */
-- headNumber(entityId) { return headNumber(assertEntityId(entityId)); },
++ async headNumber(h, entityId) { return headNumber(h, assertEntityId(entityId)); },
-- get(entityId, number) { return shape(q.byNumber.get(assertEntityId(entityId), number)); },
++ async get(h, entityId, number) { return shape(await byNumber(h, assertEntityId(entityId), number)); },
-- getById(id) { return shape(q.byId.get(String(id))); },
++ /** true when (entityId, number) exists, without reading the content. */
++ async exists(h, entityId, number) {
++ const n = toInt4(number);
++ if (n == null || n < 1) return false;
++ return Boolean(await h.maybe(sql`SELECT 1 AS ok FROM ${t$} WHERE entity_id = ${assertEntityId(entityId)} AND number = ${n}`));
++ },
-- /** Newest first. `before` is an exclusive revision number cursor. */
-- list(entityId, { limit = 50, before } = {}) {
-- const lim = Math.max(1, Math.min(500, Number(limit) || 50));
-- const cursor = before == null ? Number.MAX_SAFE_INTEGER : Number(before);
-- return q.list.all(assertEntityId(entityId), cursor, lim).map(shape);
++ /**
++ * Several revisions in one query (a sitemap or feed of published entities, no N+1):
++ * refs [{ entityId, revision }] (up to 1000) → revisions in the same order, null where absent.
++ */
++ async getMany(h, refs = []) {
++ const keys = revisionRefs(refs).filter((k) => k.number != null);
++ if (!keys.length) return refs.map(() => null);
++ const rows = await h.many(sql`SELECT r.* FROM unnest(${keys.map((k) => k.entityId)}::text[], ${keys.map((k) => k.number)}::integer[]) AS k(entity_id, number)
++ JOIN ${t$} r ON r.entity_id = k.entity_id AND r.number = k.number`);
++ const by = new Map(rows.map((r) => [refKey(r.entity_id, r.number), r]));
++ return revisionRefs(refs).map((k) => (k.number == null ? null : shape(by.get(refKey(k.entityId, k.number)))));
},
-- /** Walks parent pointers from `number` (default: head) back to revision 1. */
-- lineage(entityId, number) {
-- let row = number == null ? q.head.get(assertEntityId(entityId)) : q.byNumber.get(assertEntityId(entityId), number);
-- const out = [];
-- while (row) {
-- out.push(shape(row));
-- row = row.parent_id ? q.byId.get(row.parent_id) : null;
++ async getById(h, id) { return shape(await h.maybe(sql`SELECT * FROM ${t$} WHERE id = ${String(id)}`)); },
++
++ /** Newest first. `before` is an exclusive revision number cursor (keyset). */
++ async list(h, entityId, { limit = 50, before } = {}) {
++ const lim = boundedLimit(limit, 50, 500);
++ let cursor = sql``;
++ if (before != null) {
++ const b = Number(before);
++ if (!Number.isFinite(b)) throw new TypeError('before must be a revision number');
++ cursor = sql` AND number < ${Math.min(2147483647, Math.max(-2147483648, Math.ceil(b)))}`;
}
-- return out;
++ return (await h.many(sql`SELECT * FROM ${t$} WHERE entity_id = ${assertEntityId(entityId)}${cursor} ORDER BY number DESC LIMIT ${lim}`)).map(shape);
++ },
++
++ /** Walks parent pointers from `number` (default: head) back to revision 1, in one query. */
++ async lineage(h, entityId, number, { limit = 1000 } = {}) {
++ assertEntityId(entityId);
++ const lim = boundedLimit(limit, 1000, 10000);
++ let start;
++ if (number == null) start = sql`(SELECT max(number) FROM ${t$} WHERE entity_id = ${entityId})`;
++ else {
++ const n = toInt4(number);
++ if (n == null) return [];
++ start = sql`${n}`;
++ }
++ const rows = await h.many(sql`WITH RECURSIVE chain AS (
++ SELECT r.*, 1 AS depth FROM ${t$} r WHERE r.entity_id = ${entityId} AND r.number = ${start}
++ UNION ALL
++ SELECT r.*, c.depth + 1 FROM ${t$} r JOIN chain c ON r.id = c.parent_id WHERE c.depth < ${lim})
++ SELECT * FROM chain ORDER BY depth`);
++ return rows.map(shape);
},
/**
* New immutable revision. Returns { revision, created }. An edit identical to the head
* (same content and fields) returns the head with created: false unless allowUnchanged.
*/
-- create({ entityId, expectedRevision, content, fields = {}, meta = {}, author = null, message = null, kind = 'edit', allowUnchanged = false } = {}) {
++ async create(h, { entityId, expectedRevision, content, fields = {}, meta = {}, author = null, message = null, kind = 'edit', allowUnchanged = false } = {}) {
if (kind === 'revert') throw new TypeError('use revert() to create a revert revision');
-- return tx(() => insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, allowUnchanged }))();
++ const o = { entityId, expectedRevision, content, fields, meta, author, message, kind, allowUnchanged };
++ checkWrite(o);
++ return h.tx(async (t) => { await lock(t, entityId); return insertRevision(t, o); });
},
/** Revert as a new revision: copies revision `toRevision`'s content and fields; history is never rewritten. */
-- revert({ entityId, toRevision, expectedRevision, author = null, message = null, meta = {} } = {}) {
-- return tx(() => {
-- const target = q.byNumber.get(assertEntityId(entityId), toRevision);
++ async revert(h, { entityId, toRevision, expectedRevision, author = null, message = null, meta = {} } = {}) {
++ assertEntityId(entityId);
++ assertRevisionNumber(expectedRevision, 'expectedRevision');
++ if (meta != null && (typeof meta !== 'object' || Array.isArray(meta))) throw new TypeError('meta must be an object');
++ return h.tx(async (t) => {
++ await lock(t, entityId);
++ const target = await byNumber(t, entityId, toRevision);
if (!target) throw new PublishingError(404, 'revision.not_found', `No revision ${toRevision} of ${entityId}`);
-- return insertRevision({
-- entityId, expectedRevision, content: target.content, fields: parseJson(target.fields, {}),
-- meta: { ...meta, revertedFrom: headNumber(entityId) }, author,
++ return insertRevision(t, {
++ entityId, expectedRevision, content: target.content, fields: jsonValue(target.fields, {}),
++ meta: (current) => ({ ...meta, revertedFrom: current }), author,
message: message == null ? `Revert to revision ${toRevision}` : message,
kind: 'revert', revertedTo: toRevision, allowUnchanged: true,
});
-- })();
++ });
},
-- /** Line or word diff of the content plus a field-by-field comparison. */
-- diff(entityId, fromNumber, toNumber, { mode = 'line' } = {}) {
-- const a = q.byNumber.get(assertEntityId(entityId), fromNumber);
-- const b = q.byNumber.get(entityId, toNumber);
++ /** Line or word diff of the content plus a field-by-field comparison (both revisions in one query). */
++ async diff(h, entityId, fromNumber, toNumber, { mode = 'line' } = {}) {
++ assertEntityId(entityId);
++ const nums = [toInt4(fromNumber), toInt4(toNumber)].filter((n) => n != null);
++ const rows = nums.length ? await h.many(sql`SELECT * FROM ${t$} WHERE entity_id = ${entityId} AND number = ANY(${nums}::integer[])`) : [];
++ const a = rows.find((r) => r.number === toInt4(fromNumber));
++ const b = rows.find((r) => r.number === toInt4(toNumber));
if (!a || !b) throw new PublishingError(404, 'revision.not_found', `No revision ${!a ? fromNumber : toNumber} of ${entityId}`);
-- const fa = parseJson(a.fields, {});
-- const fb = parseJson(b.fields, {});
++ const fa = jsonValue(a.fields, {});
++ const fb = jsonValue(b.fields, {});
const fields = [];
for (const key of [...new Set([...Object.keys(fa), ...Object.keys(fb)])].sort()) {
if (stableStringify(fa[key]) !== stableStringify(fb[key])) fields.push({ field: key, from: fa[key] === undefined ? null : fa[key], to: fb[key] === undefined ? null : fb[key] });
@@@ -238,65 +283,82 @@ function createRevisionStore(db, { prefix, now } = {}) {
return { entityId, from: fromNumber, to: toNumber, content: diffText(a.content, b.content, { mode }), fields };
},
-- /** Save (create or replace) the owner's working copy. baseRevision defaults to the current head. */
-- saveDraft({ entityId, owner, content, fields = {}, meta = {}, baseRevision } = {}) {
++ /** Save (create or replace) the owner's working copy. baseRevision defaults to the current head. One statement. */
++ async saveDraft(h, { entityId, owner, content, fields = {}, meta = {}, baseRevision } = {}) {
assertEntityId(entityId);
if (typeof owner !== 'string' || !owner) throw new TypeError('owner must be a subject string');
if (typeof content !== 'string') throw new TypeError('content must be a string');
-- const base = baseRevision == null ? headNumber(entityId) : assertRevisionNumber(baseRevision, 'baseRevision');
-- q.upsertDraft.run({ entity_id: entityId, owner, base_revision: base, content, fields: JSON.stringify(fields || {}), meta: JSON.stringify(meta || {}), now: clock() });
-- return store.getDraft(entityId, owner);
++ const base = baseRevision == null ? headNum(entityId) : sql`${assertRevisionNumber(baseRevision, 'baseRevision')}::integer`;
++ const at = clock();
++ const row = await h.one(sql`INSERT INTO ${d$} AS d (entity_id, owner, base_revision, content, fields, meta, created_at, updated_at)
++ VALUES (${entityId}, ${owner}, ${base}, ${content}, ${sql.json(fields || {})}, ${sql.json(meta || {})}, ${at}, ${at})
++ ON CONFLICT (entity_id, owner) DO UPDATE SET base_revision = excluded.base_revision, content = excluded.content,
++ fields = excluded.fields, meta = excluded.meta, updated_at = excluded.updated_at
++ RETURNING d.*, ${headNum(entityId)} AS head_number`);
++ return draftShape(row);
},
-- getDraft(entityId, owner) {
-- const row = q.draft.get(assertEntityId(entityId), String(owner));
-- if (!row) return null;
-- return {
-- entityId: row.entity_id, owner: row.owner, baseRevision: row.base_revision, content: row.content,
-- fields: parseJson(row.fields, {}), meta: parseJson(row.meta, {}),
-- createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
-- stale: row.base_revision !== headNumber(row.entity_id),
-- };
++ async getDraft(h, entityId, owner) {
++ assertEntityId(entityId);
++ return draftShape(await h.maybe(sql`SELECT d.*, ${headNum(entityId)} AS head_number FROM ${d$} d WHERE d.entity_id = ${entityId} AND d.owner = ${String(owner)}`));
},
-- drafts(entityId) {
-- return q.draftsFor.all(assertEntityId(entityId)).map((row) => store.getDraft(row.entity_id, row.owner));
++ /** Every draft of an entity, most recently updated first, in one query. */
++ async drafts(h, entityId, { limit = 500 } = {}) {
++ assertEntityId(entityId);
++ const rows = await h.many(sql`SELECT d.*, ${headNum(entityId)} AS head_number FROM ${d$} d
++ WHERE d.entity_id = ${entityId} ORDER BY d.updated_at DESC, d.owner LIMIT ${boundedLimit(limit, 500, 10000)}`);
++ return rows.map(draftShape);
},
-- discardDraft(entityId, owner) { return q.deleteDraft.run(assertEntityId(entityId), String(owner)).changes > 0; },
++ async discardDraft(h, entityId, owner) {
++ return (await h.exec(sql`DELETE FROM ${d$} WHERE entity_id = ${assertEntityId(entityId)} AND owner = ${String(owner)}`)) > 0;
++ },
/**
* Turn a draft into a revision, based on the revision the draft started from. If someone
* published in between, this is a 412 conflict and the draft is kept.
*/
-- commitDraft({ entityId, owner, message = null, author } = {}) {
-- return tx(() => {
-- const d = q.draft.get(assertEntityId(entityId), String(owner));
++ async commitDraft(h, { entityId, owner, message = null, author } = {}) {
++ assertEntityId(entityId);
++ return h.tx(async (t) => {
++ await lock(t, entityId);
++ const d = await t.maybe(sql`SELECT * FROM ${d$} WHERE entity_id = ${entityId} AND owner = ${String(owner)}`);
if (!d) throw new PublishingError(404, 'draft.not_found', `No draft of ${entityId} for ${owner}`);
-- const out = insertRevision({
-- entityId, expectedRevision: d.base_revision, content: d.content, fields: parseJson(d.fields, {}),
-- meta: parseJson(d.meta, {}), author: author || owner, message, kind: 'edit',
++ const out = await insertRevision(t, {
++ entityId, expectedRevision: d.base_revision, content: d.content, fields: jsonValue(d.fields, {}),
++ meta: jsonValue(d.meta, {}), author: author || owner, message, kind: 'edit',
});
-- q.deleteDraft.run(entityId, owner);
++ await t.exec(sql`DELETE FROM ${d$} WHERE entity_id = ${entityId} AND owner = ${String(owner)}`);
return out;
-- })();
++ });
},
/**
* Explicit, audited erasure of every revision and draft of one entity (legal removal).
* The purge row stays as the record that it happened.
*/
-- purgeEntity(entityId, { reason, purgedBy = null } = {}) {
++ async purgeEntity(h, entityId, { reason, purgedBy = null } = {}) {
if (!reason) throw new TypeError('a purge needs a recorded reason');
-- return tx(() => {
-- db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());
-- const n = db.prepare(`DELETE FROM ${T} WHERE entity_id = ?`).run(entityId).changes;
-- db.prepare(`DELETE FROM ${D} WHERE entity_id = ?`).run(entityId);
++ assertEntityId(entityId);
++ return h.tx(async (t) => {
++ await lock(t, entityId);
++ await t.exec(sql`INSERT INTO ${p$} (entity_id, reason, purged_by, purged_at) VALUES (${entityId}, ${String(reason)}, ${purgedBy}, ${clock()})
++ ON CONFLICT (entity_id) DO NOTHING`);
++ const n = await t.exec(sql`DELETE FROM ${t$} WHERE entity_id = ${entityId}`);
++ await t.exec(sql`DELETE FROM ${d$} WHERE entity_id = ${entityId}`);
return { deleted: n };
-- })();
++ });
},
};
++
++ const store = bindHandles(db, impl);
++ store.prefix = prefix;
++ store.tables = { revisions: T, drafts: D, purges: P };
++ store.schema = () => schema(prefix);
++ /** Create the tables where the handle may (tests, PGlite); services put schema() in a migration. */
++ store.ensureSchema = async () => { await db.query(schema(prefix)); return store; };
return store;
}
--module.exports = { createRevisionStore, diffText, PublishingError, KINDS };
++module.exports = { createRevisionStore, schema, diffText, PublishingError, KINDS };
ddiff --git a/lib/schedule.js b/lib/schedule.js
iindex f5e4b50..a9d2e03 100644
---- a/lib/schedule.js
++++ b/lib/schedule.js
@@@ -2,16 +2,17 @@
/**
* openvibe-publishing/schedule — scheduled publish/unpublish jobs that survive worker restarts.
*
-- * const { createScheduler } = require('openvibe-publishing/schedule');
++ * const { createScheduler, schema } = require('openvibe-publishing/schedule');
* const sched = createScheduler(db, { prefix: 'blog', leaseMs: 60000, now: () => Date.now() });
-- * sched.schedule({ entityId: 'post_1', action: 'publish', runAt: Date.parse('2026-10-01T09:00:00Z'), revision: 3 });
-- * // in the product's worker loop:
-- * await sched.runDue({ worker: 'blog-1', handler: async (job) => { publishPost(job.entityId, job.revision); } });
++ * await sched.schedule({ entityId: 'post_1', action: 'publish', runAt: Date.parse('2026-10-01T09:00:00Z'), revision: 3 });
++ * // in the product's worker loop (any number of processes and hosts):
++ * await sched.runDue({ worker: 'blog-1', handler: async (job) => { await publishPost(job.entityId, job.revision); } });
*
* Table <prefix>_schedule_jobs. Guarantees:
* - schedule() is idempotent: the same (entity, action, runAt, revision) — or the same explicit
* `key` — returns the existing job instead of adding a second one.
-- * - claim() takes a lease (lease_owner, lease_until). A worker that dies mid-job leaves the lease
++ * - claim() takes a lease (lease_owner, lease_until) in one UPDATE … FOR UPDATE SKIP LOCKED, so
++ * concurrent workers never take the same job. A worker that dies mid-job leaves the lease
* to expire, and the next claim re-runs the job: at-least-once. The handler must therefore be
* re-run safe ("make revision N the published one" is; "append a publish" is not). Each job
* carries a stable `key` the handler can use as its own idempotency key.
@@@ -21,7 +22,9 @@
* its `<product>.schedule.failed` event from runDue's summary).
* The clock is injectable (`now`), so tests and replays are deterministic.
*/
--const { PublishingError, assertDb, assertPrefix, clockOf, newId, parseJson, assertEntityId } = require('./internal');
++const {
++ PublishingError, assertDb, sqlOf, bindHandles, boundedLimit, jsonValue, assertPrefix, clockOf, newId, assertEntityId,
++} = require('./internal');
const ACTIONS = ['publish', 'unpublish'];
const STATUSES = ['pending', 'running', 'done', 'failed', 'cancelled'];
@@@ -40,7 +43,7 @@ function shape(row) {
leaseOwner: row.lease_owner,
leaseUntil: row.lease_until == null ? null : new Date(row.lease_until).toISOString(),
lastError: row.last_error,
-- result: parseJson(row.result, null),
++ result: jsonValue(row.result, null),
createdAt: new Date(row.created_at).toISOString(),
updatedAt: new Date(row.updated_at).toISOString(),
};
@@@ -52,154 +55,167 @@ function toMs(v, name) {
return t;
}
++const byRunAt = (a, b) => (a.run_at - b.run_at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
++
++/** The DDL for one prefix (idempotent), for the service's migration file. */
++function schema(prefix) {
++ assertPrefix(prefix);
++ const J = `${prefix}_schedule_jobs`;
++ return `CREATE TABLE IF NOT EXISTS ${J} (
++ id text COLLATE "C" PRIMARY KEY,
++ idem_key text NOT NULL UNIQUE,
++ entity_id text COLLATE "C" NOT NULL,
++ action text NOT NULL CHECK (action IN ('publish','unpublish')),
++ revision integer,
++ run_at bigint NOT NULL,
++ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),
++ attempts integer NOT NULL DEFAULT 0,
++ lease_owner text,
++ lease_until bigint,
++ last_error text,
++ result jsonb,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS ${J}_due ON ${J} (run_at, id) WHERE status = 'pending';
++CREATE INDEX IF NOT EXISTS ${J}_lease ON ${J} (lease_until) WHERE status = 'running';
++CREATE INDEX IF NOT EXISTS ${J}_entity ON ${J} (entity_id, run_at, id);
++`;
++}
++
function createScheduler(db, { prefix, now, leaseMs = 60000, maxAttempts = 5, backoffMs = [5000, 30000, 120000, 600000] } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
if (!(leaseMs > 0)) throw new TypeError('leaseMs must be positive');
++ if (!Array.isArray(backoffMs) || !backoffMs.every((n) => Number.isSafeInteger(n) && n >= 0)) throw new TypeError('backoffMs must be a list of non-negative integers');
const J = `${prefix}_schedule_jobs`;
--
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${J} (
-- id TEXT PRIMARY KEY,
-- idem_key TEXT NOT NULL UNIQUE,
-- entity_id TEXT NOT NULL,
-- action TEXT NOT NULL CHECK (action IN ('publish','unpublish')),
-- revision INTEGER,
-- run_at INTEGER NOT NULL,
-- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),
-- attempts INTEGER NOT NULL DEFAULT 0,
-- lease_owner TEXT,
-- lease_until INTEGER,
-- last_error TEXT,
-- result TEXT,
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL
-- );
-- CREATE INDEX IF NOT EXISTS ${J}_due ON ${J} (status, run_at);
-- CREATE INDEX IF NOT EXISTS ${J}_entity ON ${J} (entity_id, run_at);
-- `);
--
-- const q = {
-- byId: db.prepare(`SELECT * FROM ${J} WHERE id = ?`),
-- byKey: db.prepare(`SELECT * FROM ${J} WHERE idem_key = ?`),
-- insert: db.prepare(`INSERT OR IGNORE INTO ${J} (id, idem_key, entity_id, action, revision, run_at, created_at, updated_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
-- due: db.prepare(`SELECT * FROM ${J} WHERE (status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now)
-- ORDER BY run_at, id LIMIT @limit`),
-- take: db.prepare(`UPDATE ${J} SET status = 'running', lease_owner = @worker, lease_until = @until, attempts = attempts + 1, updated_at = @now
-- WHERE id = @id AND ((status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now))`),
-- complete: db.prepare(`UPDATE ${J} SET status = 'done', result = @result, lease_owner = NULL, lease_until = NULL, last_error = NULL, updated_at = @now
-- WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
-- retry: db.prepare(`UPDATE ${J} SET status = 'pending', run_at = @runAt, last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now
-- WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
-- dead: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now
-- WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
-- abandon: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = 'lease expired on every attempt', lease_owner = NULL, lease_until = NULL, updated_at = @now
-- WHERE id = @id AND status = 'running' AND lease_until <= @now`),
-- cancel: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'pending'`),
-- cancelEntity: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE entity_id = ? AND status = 'pending' AND (? IS NULL OR action = ?)`),
-- forEntity: db.prepare(`SELECT * FROM ${J} WHERE entity_id = ? ORDER BY run_at, id`),
-- };
--
-- function backoff(attempts) {
-- return backoffMs[Math.min(attempts - 1, backoffMs.length - 1)] || 0;
-- }
--
-- function claimDue(worker, limit) {
++ const j$ = sql.ident(J);
++
++ /**
++ * Lease up to `limit` due jobs in one statement: pending and due, or running with an expired
++ * lease. FOR UPDATE SKIP LOCKED lets any number of workers (processes, hosts) claim from one table
++ * without taking the same job twice. A job whose workers keep dying stops after maxAttempts
++ * (marked failed, reported as abandoned) instead of looping forever.
++ */
++ async function claimDue(h, worker, limit) {
if (typeof worker !== 'string' || !worker) throw new TypeError('worker must be a non-empty string');
-- return db.transaction(() => {
-- const t = clock();
-- const rows = q.due.all({ now: t, limit: Math.max(1, Math.min(100, limit)) });
-- const jobs = [];
-- const abandoned = [];
-- for (const r of rows) {
-- // A job whose workers keep dying stops after maxAttempts instead of looping forever.
-- if (r.status === 'running' && r.attempts >= maxAttempts) {
-- if (q.abandon.run({ id: r.id, now: t }).changes === 1) abandoned.push(shape(q.byId.get(r.id)));
-- continue;
-- }
-- if (q.take.run({ id: r.id, worker, until: t + leaseMs, now: t }).changes === 1) jobs.push(shape(q.byId.get(r.id)));
-- }
-- return { jobs, abandoned };
-- }).immediate();
++ const t = clock();
++ const lim = boundedLimit(limit, 10, 100);
++ const rows = await h.many(sql`WITH due AS (
++ SELECT id, (status = 'running' AND attempts >= ${maxAttempts}::integer) AS dead FROM ${j$}
++ WHERE (status = 'pending' AND run_at <= ${t}::bigint) OR (status = 'running' AND lease_until <= ${t}::bigint)
++ ORDER BY run_at, id LIMIT ${lim}
++ FOR UPDATE SKIP LOCKED)
++ UPDATE ${j$} AS j SET
++ status = CASE WHEN due.dead THEN 'failed' ELSE 'running' END,
++ lease_owner = CASE WHEN due.dead THEN NULL ELSE ${worker}::text END,
++ lease_until = CASE WHEN due.dead THEN NULL ELSE ${t + leaseMs}::bigint END,
++ attempts = CASE WHEN due.dead THEN j.attempts ELSE j.attempts + 1 END,
++ last_error = CASE WHEN due.dead THEN 'lease expired on every attempt' ELSE j.last_error END,
++ updated_at = ${t}::bigint
++ FROM due WHERE j.id = due.id
++ RETURNING j.*, due.dead`);
++ rows.sort(byRunAt);
++ return { jobs: rows.filter((r) => !r.dead).map(shape), abandoned: rows.filter((r) => r.dead).map(shape) };
}
-- const api = {
-- table: J,
-- ACTIONS,
-- STATUSES,
++ async function completeRow(h, id, worker, result) {
++ return h.maybe(sql`UPDATE ${j$} SET status = 'done', result = ${sql.json(result == null ? null : result)}, lease_owner = NULL, lease_until = NULL,
++ last_error = NULL, updated_at = ${clock()}
++ WHERE id = ${String(id)} AND status = 'running' AND lease_owner = ${String(worker)}
++ RETURNING *`);
++ }
++ const impl = {
/** Idempotent. Returns { job, created }. */
-- schedule({ entityId, action, runAt, revision = null, key } = {}) {
++ async schedule(h, { entityId, action, runAt, revision = null, key } = {}) {
assertEntityId(entityId);
if (!ACTIONS.includes(action)) throw new TypeError(`action must be one of ${ACTIONS.join(', ')}`);
const at = toMs(runAt, 'runAt');
-- if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');
++ if (revision != null && (!Number.isInteger(revision) || revision < 1 || revision > 2147483647)) throw new TypeError('revision must be a positive integer');
const idem = key ? String(key) : `${entityId}:${action}:${at}:${revision == null ? '-' : revision}`;
const t = clock();
-- const info = q.insert.run(newId('job', t), idem, entityId, action, revision, at, t, t);
-- return { job: shape(q.byKey.get(idem)), created: info.changes === 1 };
++ const row = await h.maybe(sql`INSERT INTO ${j$} (id, idem_key, entity_id, action, revision, run_at, created_at, updated_at)
++ VALUES (${newId('job', t)}, ${idem}, ${entityId}, ${action}, ${revision}, ${at}, ${t}, ${t})
++ ON CONFLICT (idem_key) DO NOTHING RETURNING *`);
++ if (row) return { job: shape(row), created: true };
++ return { job: shape(await h.one(sql`SELECT * FROM ${j$} WHERE idem_key = ${idem}`)), created: false };
},
-- get(id) { return shape(q.byId.get(String(id))); },
++ async get(h, id) { return shape(await h.maybe(sql`SELECT * FROM ${j$} WHERE id = ${String(id)}`)); },
-- jobs(entityId) { return q.forEntity.all(assertEntityId(entityId)).map(shape); },
++ async jobs(h, entityId, { limit = 1000 } = {}) {
++ return (await h.many(sql`SELECT * FROM ${j$} WHERE entity_id = ${assertEntityId(entityId)} ORDER BY run_at, id LIMIT ${boundedLimit(limit)}`)).map(shape);
++ },
/** Cancel one pending job (a running job finishes or its lease expires; it cannot be cancelled mid-flight). */
-- cancel(id) { return q.cancel.run(clock(), String(id)).changes > 0; },
++ async cancel(h, id) {
++ return (await h.exec(sql`UPDATE ${j$} SET status = 'cancelled', updated_at = ${clock()} WHERE id = ${String(id)} AND status = 'pending'`)) > 0;
++ },
/** Cancel every pending job of an entity, optionally only one action. Returns the count. */
-- cancelPending(entityId, action = null) {
-- return q.cancelEntity.run(clock(), assertEntityId(entityId), action, action).changes;
++ async cancelPending(h, entityId, action = null) {
++ assertEntityId(entityId);
++ const only = action == null ? sql`` : sql` AND action = ${String(action)}`;
++ return h.exec(sql`UPDATE ${j$} SET status = 'cancelled', updated_at = ${clock()} WHERE entity_id = ${entityId} AND status = 'pending'${only}`);
},
/** Lease up to `limit` due jobs (pending and due, or running with an expired lease). */
-- claim({ worker, limit = 10 } = {}) { return claimDue(worker, limit).jobs; },
++ async claim(h, { worker, limit = 10 } = {}) { return (await claimDue(h, worker, limit)).jobs; },
/** true when recorded; false when this worker no longer holds the lease. */
-- complete(id, worker, result = null) {
-- return q.complete.run({ id: String(id), worker, result: result == null ? null : JSON.stringify(result), now: clock() }).changes === 1;
-- },
++ async complete(h, id, worker, result = null) { return Boolean(await completeRow(h, id, worker, result)); },
-- /** Returns { status: 'retry' | 'failed' | 'lost' }. */
-- fail(id, worker, error) {
-- const row = q.byId.get(String(id));
-- if (!row || row.status !== 'running' || row.lease_owner !== worker) return { status: 'lost' };
++ /** Returns { status: 'retry' | 'failed' | 'lost' }, in one statement. */
++ async fail(h, id, worker, error) {
const t = clock();
const msg = String(error && error.message ? error.message : error || 'failed').slice(0, 1000);
-- if (row.attempts >= maxAttempts) {
-- q.dead.run({ id: row.id, worker, error: msg, now: t });
-- return { status: 'failed', job: shape(q.byId.get(row.id)) };
-- }
-- q.retry.run({ id: row.id, worker, error: msg, runAt: t + backoff(row.attempts), now: t });
-- return { status: 'retry', job: shape(q.byId.get(row.id)) };
++ const row = await h.maybe(sql`UPDATE ${j$} SET
++ status = CASE WHEN attempts >= ${maxAttempts}::integer THEN 'failed' ELSE 'pending' END,
++ run_at = CASE WHEN attempts >= ${maxAttempts}::integer THEN run_at
++ ELSE ${t}::bigint + COALESCE((${backoffMs}::bigint[])[LEAST(attempts, ${backoffMs.length}::integer)], 0) END,
++ last_error = ${msg}, lease_owner = NULL, lease_until = NULL, updated_at = ${t}::bigint
++ WHERE id = ${String(id)} AND status = 'running' AND lease_owner = ${String(worker)}
++ RETURNING *`);
++ if (!row) return { status: 'lost' };
++ return { status: row.status === 'failed' ? 'failed' : 'retry', job: shape(row) };
},
/**
* Claim and run due jobs one by one. handler(job) may be async; a throw counts as a failure.
* Returns { done: [job], retried: [job], failed: [job], lost: [job] }.
*/
-- async runDue({ worker, handler, limit = 10 } = {}) {
++ async runDue(h, { worker, handler, limit = 10 } = {}) {
if (typeof handler !== 'function') throw new TypeError('handler must be a function');
-- const { jobs, abandoned } = claimDue(worker, limit);
++ const { jobs, abandoned } = await claimDue(h, worker, limit);
const summary = { done: [], retried: [], failed: [...abandoned], lost: [] };
for (const job of jobs) {
try {
const result = await handler(job);
-- if (api.complete(job.id, worker, result === undefined ? null : result)) summary.done.push(api.get(job.id));
-- else summary.lost.push(api.get(job.id));
++ const row = await completeRow(h, job.id, worker, result === undefined ? null : result);
++ if (row) summary.done.push(shape(row));
++ else summary.lost.push(await impl.get(h, job.id));
} catch (err) {
-- const f = api.fail(job.id, worker, err);
++ const f = await impl.fail(h, job.id, worker, err);
if (f.status === 'failed') summary.failed.push(f.job);
else if (f.status === 'retry') summary.retried.push(f.job);
-- else summary.lost.push(api.get(job.id));
++ else summary.lost.push(await impl.get(h, job.id));
}
}
return summary;
},
};
++
++ const api = bindHandles(db, impl);
++ api.table = J;
++ api.ACTIONS = ACTIONS;
++ api.STATUSES = STATUSES;
++ api.schema = () => schema(prefix);
++ /** Create the table where the handle may (tests, PGlite); services put schema() in a migration. */
++ api.ensureSchema = async () => { await db.query(schema(prefix)); return api; };
return api;
}
--module.exports = { createScheduler, ACTIONS, STATUSES, PublishingError };
++module.exports = { createScheduler, schema, ACTIONS, STATUSES, PublishingError };
ddiff --git a/lib/seo.js b/lib/seo.js
iindex 1288a0e..794661c 100644
---- a/lib/seo.js
++++ b/lib/seo.js
@@@ -25,7 +25,7 @@
* rule needs — there is no default that makes content indexable.
*/
const sharedSeo = require('openvibe-shared/seo');
--const { assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
++const { assertDb, sqlOf, bindHandles, boundedLimit, assertPrefix, clockOf, assertEntityId } = require('./internal');
const { wordCount } = require('./ssr');
const GATE = 'openvibe-publishing/seo@1';
@@@ -196,6 +196,20 @@ function normalizePath(p) {
return s.length > 1 ? s.replace(/\/+$/, '') : s;
}
++/** The redirect store's DDL for one prefix (idempotent), for the service's migration file. */
++function redirectsSchema(prefix) {
++ assertPrefix(prefix);
++ const R = `${prefix}_redirects`;
++ return `CREATE TABLE IF NOT EXISTS ${R} (
++ from_path text COLLATE "C" PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ reason text,
++ created_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS ${R}_entity ON ${R} (entity_id, created_at, from_path);
++`;
++}
++
/**
* History-aware redirects in <prefix>_redirects: every path an entity ever had points at the
* entity, and resolve() answers with the entity's *current* path, so chains collapse into one 301.
@@@ -203,56 +217,51 @@ function normalizePath(p) {
function createRedirectStore(db, { prefix, now } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const R = `${prefix}_redirects`;
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${R} (
-- from_path TEXT PRIMARY KEY,
-- entity_id TEXT NOT NULL,
-- reason TEXT,
-- created_at INTEGER NOT NULL
-- );
-- CREATE INDEX IF NOT EXISTS ${R}_entity ON ${R} (entity_id);
-- `);
-- const q = {
-- put: db.prepare(`INSERT INTO ${R} (from_path, entity_id, reason, created_at) VALUES (?, ?, ?, ?)
-- ON CONFLICT (from_path) DO UPDATE SET entity_id = excluded.entity_id, reason = excluded.reason, created_at = excluded.created_at`),
-- del: db.prepare(`DELETE FROM ${R} WHERE from_path = ?`),
-- get: db.prepare(`SELECT * FROM ${R} WHERE from_path = ?`),
-- history: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY created_at, from_path`),
-- };
-- return {
-- table: R,
-- /** An entity moved from oldPath to newPath (slug rename, space move). */
-- recordMove(entityId, oldPath, newPath, { reason = 'renamed' } = {}) {
++ const r$ = sql.ident(R);
++ const impl = {
++ /** An entity moved from oldPath to newPath (slug rename, space move). One statement. */
++ async recordMove(h, entityId, oldPath, newPath, { reason = 'renamed' } = {}) {
assertEntityId(entityId);
const from = normalizePath(oldPath);
const to = normalizePath(newPath);
-- db.transaction(() => {
-- q.del.run(to); // the new path is live: it must not redirect anywhere
-- if (from !== to) q.put.run(from, entityId, reason, clock());
-- })();
++ if (from === to) { await h.exec(sql`DELETE FROM ${r$} WHERE from_path = ${to}`); return; }
++ // The new path is live, so it must not redirect anywhere; the old one now points at the entity.
++ await h.exec(sql`WITH live AS (DELETE FROM ${r$} WHERE from_path = ${to})
++ INSERT INTO ${r$} (from_path, entity_id, reason, created_at) VALUES (${from}, ${entityId}, ${reason == null ? null : String(reason)}, ${clock()})
++ ON CONFLICT (from_path) DO UPDATE SET entity_id = excluded.entity_id, reason = excluded.reason, created_at = excluded.created_at`);
},
/** Claim a path for a (new) entity: removes any redirect that used it. */
-- release(path) { return q.del.run(normalizePath(path)).changes > 0; },
++ async release(h, path) { return (await h.exec(sql`DELETE FROM ${r$} WHERE from_path = ${normalizePath(path)}`)) > 0; },
/**
-- * currentPath(entityId) → the entity's live path, or null when it is gone.
++ * currentPath(entityId) → the entity's live path, or null when it is gone (it may return a promise).
* → null (not a historical path) | { status: 301, location } | { status: 410 }
*/
-- resolve(path, { currentPath } = {}) {
++ async resolve(h, path, { currentPath } = {}) {
if (typeof currentPath !== 'function') throw new TypeError('currentPath(entityId) is required');
let from;
try { from = normalizePath(path); } catch { return null; }
-- const row = q.get.get(from);
++ const row = await h.maybe(sql`SELECT entity_id FROM ${r$} WHERE from_path = ${from}`);
if (!row) return null;
-- const target = currentPath(row.entity_id);
++ const target = await currentPath(row.entity_id);
if (target == null) return { status: 410, entityId: row.entity_id };
const location = normalizePath(target);
if (location === from) return null;
return { status: 301, location, entityId: row.entity_id };
},
-- history(entityId) { return q.history.all(assertEntityId(entityId)).map((r) => ({ path: r.from_path, reason: r.reason, at: new Date(r.created_at).toISOString() })); },
++ async history(h, entityId, { limit = 1000 } = {}) {
++ return (await h.many(sql`SELECT * FROM ${r$} WHERE entity_id = ${assertEntityId(entityId)} ORDER BY created_at, from_path LIMIT ${boundedLimit(limit)}`))
++ .map((r) => ({ path: r.from_path, reason: r.reason, at: new Date(r.created_at).toISOString() }));
++ },
};
++ const api = bindHandles(db, impl);
++ api.table = R;
++ api.schema = () => redirectsSchema(prefix);
++ /** Create the table where the handle may (tests, PGlite); services put schema() in a migration. */
++ api.ensureSchema = async () => { await db.query(redirectsSchema(prefix)); return api; };
++ return api;
}
// ---- Meta tags ---------------------------------------------------------------------------------
@@@ -562,7 +571,7 @@ function jsonLdScript(obj) { return obj ? sharedSeo.jsonLdTag(obj) : ''; }
module.exports = {
GATE, REASONS, REASON_BY_CODE, STATES, VISIBILITIES, DEFAULT_POLICY,
-- evaluate, canonicalUrl, createRedirectStore, normalizePath,
++ evaluate, canonicalUrl, createRedirectStore, redirectsSchema, normalizePath,
metaTags, robotsMeta, xRobotsTag,
sitemap, sitemapIndex, robotsTxt,
rssFeed, atomFeed, jsonFeed, feedLinks,
ddiff --git a/lib/ssr.js b/lib/ssr.js
iindex f3a5983..8850a9f 100644
---- a/lib/ssr.js
++++ b/lib/ssr.js
@@@ -6,7 +6,7 @@
* ssr.html`<h1>${title}</h1>${ssr.raw(ssr.renderMarkdown(body))}` // values escaped unless raw()
* ssr.paginate({ page: 2, perPage: 20, total: 95, href: (p) => `/tags/bread?page=${p}` })
* ssr.paginationHtml(pager) ssr.breadcrumbsHtml([{ name, url }, …])
-- * ssr.diffHtml(revs.diff(id, 1, 2, { mode: 'word' }).content)
++ * ssr.diffHtml((await revs.diff(id, 1, 2, { mode: 'word' })).content)
* ssr.timeTag('2026-09-20T10:00:00Z') // '' when the date is unknown — never a guessed date
*
* Markdown safety model: the source is never trusted as HTML. Text is escaped before any tag is
ddiff --git a/lib/taxonomy.js b/lib/taxonomy.js
iindex 970d667..8fbf142 100644
---- a/lib/taxonomy.js
++++ b/lib/taxonomy.js
@@@ -2,18 +2,18 @@
/**
* openvibe-publishing/taxonomy — tags, terms with slugs and hierarchical categories.
*
-- * const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');
++ * const { createTaxonomy, slugify, schema } = require('openvibe-publishing/taxonomy');
* const tax = createTaxonomy(db, { prefix: 'blog' }); // blog_terms, blog_term_links
-- * const cooking = tax.ensureTerm({ vocabulary: 'category', name: 'Cooking' });
-- * const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: cooking.id });
-- * tax.setTerms('post_1', 'tag', ['sourdough', 'Rye Bread']); // names or term ids
-- * tax.ancestors(bread.id); // [Cooking, Bread] — breadcrumbs
-- * tax.entitiesFor(cooking.id, { includeDescendants: true });
++ * const cooking = await tax.ensureTerm({ vocabulary: 'category', name: 'Cooking' });
++ * const bread = await tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: cooking.id });
++ * await tax.setTerms('post_1', 'tag', ['sourdough', 'Rye Bread']); // names or term ids
++ * await tax.ancestors(bread.id); // [Cooking, Bread] — breadcrumbs
++ * await tax.entitiesFor(cooking.id, { includeDescendants: true });
*
* Vocabularies are free-form names ('tag', 'category', 'series', …) chosen by the product. Slugs
* are unique per vocabulary. Hierarchy is a parent pointer; cycles are refused.
*/
--const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
++const { PublishingError, assertDb, sqlOf, bindHandles, lockKey, boundedLimit, toId, assertPrefix, clockOf, assertEntityId } = require('./internal');
const VOCAB_RE = /^[a-z][a-z0-9_]{0,39}$/;
@@@ -37,177 +37,268 @@ function shape(row) {
return { id: row.id, vocabulary: row.vocabulary, slug: row.slug, name: row.name, parentId: row.parent_id, description: row.description };
}
++function cleanName(name) {
++ return String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 200);
++}
++
++/** The DDL for one prefix (idempotent), for the service's migration file. */
++function schema(prefix) {
++ assertPrefix(prefix);
++ const T = `${prefix}_terms`;
++ const L = `${prefix}_term_links`;
++ return `CREATE TABLE IF NOT EXISTS ${T} (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ vocabulary text NOT NULL,
++ slug text NOT NULL,
++ name text NOT NULL,
++ parent_id bigint REFERENCES ${T}(id),
++ description text,
++ created_at bigint NOT NULL,
++ UNIQUE (vocabulary, slug)
++);
++CREATE INDEX IF NOT EXISTS ${T}_parent ON ${T} (parent_id, name);
++CREATE INDEX IF NOT EXISTS ${T}_vocab ON ${T} (vocabulary, name);
++CREATE TABLE IF NOT EXISTS ${L} (
++ entity_id text COLLATE "C" NOT NULL,
++ term_id bigint NOT NULL REFERENCES ${T}(id),
++ position integer NOT NULL DEFAULT 0,
++ created_at bigint NOT NULL,
++ PRIMARY KEY (entity_id, term_id)
++);
++CREATE INDEX IF NOT EXISTS ${L}_term ON ${L} (term_id, entity_id);
++`;
++}
++
function createTaxonomy(db, { prefix, now } = {}) {
assertDb(db);
assertPrefix(prefix);
++ const sql = sqlOf(db);
const clock = clockOf(now);
const T = `${prefix}_terms`;
const L = `${prefix}_term_links`;
--
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS ${T} (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- vocabulary TEXT NOT NULL,
-- slug TEXT NOT NULL,
-- name TEXT NOT NULL,
-- parent_id INTEGER REFERENCES ${T}(id),
-- description TEXT,
-- created_at INTEGER NOT NULL,
-- UNIQUE (vocabulary, slug)
-- );
-- CREATE INDEX IF NOT EXISTS ${T}_parent ON ${T} (parent_id);
-- CREATE TABLE IF NOT EXISTS ${L} (
-- entity_id TEXT NOT NULL,
-- term_id INTEGER NOT NULL REFERENCES ${T}(id),
-- position INTEGER NOT NULL DEFAULT 0,
-- created_at INTEGER NOT NULL,
-- PRIMARY KEY (entity_id, term_id)
-- );
-- CREATE INDEX IF NOT EXISTS ${L}_term ON ${L} (term_id);
-- `);
--
-- const q = {
-- byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),
-- bySlug: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? AND slug = ?`),
-- insert: db.prepare(`INSERT INTO ${T} (vocabulary, slug, name, parent_id, description, created_at) VALUES (?, ?, ?, ?, ?, ?)`),
-- setParent: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE id = ?`),
-- rename: db.prepare(`UPDATE ${T} SET name = ? WHERE id = ?`),
-- children: db.prepare(`SELECT * FROM ${T} WHERE parent_id = ? ORDER BY name`),
-- vocab: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? ORDER BY name`),
-- ancestors: db.prepare(`WITH RECURSIVE up(id, parent_id, depth) AS (
-- SELECT id, parent_id, 0 FROM ${T} WHERE id = ?
-- UNION ALL SELECT t.id, t.parent_id, up.depth + 1 FROM ${T} t JOIN up ON t.id = up.parent_id WHERE up.depth < 64)
-- SELECT t.* FROM up JOIN ${T} t ON t.id = up.id ORDER BY up.depth DESC`),
-- descendants: db.prepare(`WITH RECURSIVE down(id, depth) AS (
-- SELECT id, 0 FROM ${T} WHERE id = ?
-- UNION ALL SELECT t.id, down.depth + 1 FROM ${T} t JOIN down ON t.parent_id = down.id WHERE down.depth < 64)
-- SELECT t.* FROM down JOIN ${T} t ON t.id = down.id WHERE down.depth > 0 ORDER BY t.name`),
-- linksFor: db.prepare(`SELECT t.*, l.position FROM ${L} l JOIN ${T} t ON t.id = l.term_id WHERE l.entity_id = ? ORDER BY t.vocabulary, l.position, t.name`),
-- unlinkVocab: db.prepare(`DELETE FROM ${L} WHERE entity_id = ? AND term_id IN (SELECT id FROM ${T} WHERE vocabulary = ?)`),
-- link: db.prepare(`INSERT OR IGNORE INTO ${L} (entity_id, term_id, position, created_at) VALUES (?, ?, ?, ?)`),
-- unlinkTerm: db.prepare(`DELETE FROM ${L} WHERE term_id = ?`),
-- reparentChildren: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE parent_id = ?`),
-- deleteTerm: db.prepare(`DELETE FROM ${T} WHERE id = ?`),
-- };
++ const t$ = sql.ident(T);
++ const l$ = sql.ident(L);
++ const lockTree = (t) => lockKey(t, sql, `${T}:tree`);
function vocabOf(v) {
if (typeof v !== 'string' || !VOCAB_RE.test(v)) throw new TypeError(`vocabulary must match ${VOCAB_RE}`);
return v;
}
-- function mustGet(id) {
-- const row = q.byId.get(Number(id));
++ const byId = (h, id) => { const n = toId(id); return n == null ? null : h.maybe(sql`SELECT * FROM ${t$} WHERE id = ${n}`); };
++ const bySlug = (h, vocabulary, slug) => h.maybe(sql`SELECT * FROM ${t$} WHERE vocabulary = ${vocabulary} AND slug = ${String(slug)}`);
++
++ async function mustGet(h, id) {
++ const row = await byId(h, id);
if (!row) throw new PublishingError(404, 'term.not_found', `No term ${id}`);
return row;
}
-- function checkParent(termId, parentId, vocabulary) {
++ const ancestorsSql = (id) => sql`WITH RECURSIVE up(id, parent_id, depth) AS (
++ SELECT id, parent_id, 0 FROM ${t$} WHERE id = ${id}
++ UNION ALL SELECT t.id, t.parent_id, up.depth + 1 FROM ${t$} t JOIN up ON t.id = up.parent_id WHERE up.depth < 64)
++ SELECT t.* FROM up JOIN ${t$} t ON t.id = up.id ORDER BY up.depth DESC`;
++ const descendantIds = (id) => sql`WITH RECURSIVE down(id, depth) AS (
++ SELECT id, 0 FROM ${t$} WHERE id = ${id}
++ UNION ALL SELECT t.id, down.depth + 1 FROM ${t$} t JOIN down ON t.parent_id = down.id WHERE down.depth < 64)`;
++
++ async function checkParent(h, termId, parentId, vocabulary) {
if (parentId == null) return null;
-- const parent = mustGet(parentId);
++ const parent = await mustGet(h, parentId);
if (parent.vocabulary !== vocabulary) throw new PublishingError(400, 'term.parent_vocabulary', 'A parent must be in the same vocabulary');
if (termId != null) {
-- const chain = q.ancestors.all(parent.id).map((r) => r.id);
++ const chain = (await h.many(ancestorsSql(parent.id))).map((r) => r.id);
if (chain.includes(Number(termId))) throw new PublishingError(400, 'term.cycle', 'That parent would create a cycle');
}
return parent.id;
}
-- const api = {
-- tables: { terms: T, links: L },
++ async function termsFor(h, entityId, vocabulary, limit) {
++ const only = vocabulary ? sql` AND t.vocabulary = ${vocabulary}` : sql``;
++ return (await h.many(sql`SELECT t.*, l.position FROM ${l$} l JOIN ${t$} t ON t.id = l.term_id
++ WHERE l.entity_id = ${entityId}${only} ORDER BY t.vocabulary, l.position, t.name LIMIT ${boundedLimit(limit)}`)).map(shape);
++ }
-- get(id) { return shape(q.byId.get(Number(id))); },
-- bySlug(vocabulary, slug) { return shape(q.bySlug.get(vocabOf(vocabulary), String(slug))); },
++ const impl = {
++ async get(h, id) { return shape(await byId(h, id)); },
++ async bySlug(h, vocabulary, slug) { return shape(await bySlug(h, vocabOf(vocabulary), slug)); },
/** Get-or-create by slug (derived from name unless given). The existing term is returned unchanged. */
-- ensureTerm({ vocabulary, name, slug, parentId = null, description = null } = {}) {
++ async ensureTerm(h, { vocabulary, name, slug, parentId = null, description = null } = {}) {
vocabOf(vocabulary);
-- const label = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 200);
++ const label = cleanName(name);
if (!label) throw new TypeError('name is required');
const s = slug ? slugify(slug) : slugify(label);
-- return db.transaction(() => {
-- const existing = q.bySlug.get(vocabulary, s);
-- if (existing) return shape(existing);
-- const pid = checkParent(null, parentId, vocabulary);
-- const info = q.insert.run(vocabulary, s, label, pid, description, clock());
-- return shape(q.byId.get(info.lastInsertRowid));
-- })();
++ const existing = await bySlug(h, vocabulary, s);
++ if (existing) return shape(existing);
++ const pid = await checkParent(h, null, parentId, vocabulary);
++ const row = await h.maybe(sql`INSERT INTO ${t$} (vocabulary, slug, name, parent_id, description, created_at)
++ VALUES (${vocabulary}, ${s}, ${label}, ${pid}, ${description == null ? null : String(description)}, ${clock()})
++ ON CONFLICT (vocabulary, slug) DO NOTHING RETURNING *`);
++ return shape(row || await bySlug(h, vocabulary, s)); // a concurrent ensureTerm won: theirs is the term
},
-- rename(id, name) {
-- mustGet(id);
-- const label = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 200);
-- if (!label) throw new TypeError('name is required');
-- q.rename.run(label, Number(id));
-- return api.get(id);
++ async rename(h, id, name) {
++ const label = cleanName(name);
++ if (!label) { await mustGet(h, id); throw new TypeError('name is required'); }
++ const n = toId(id);
++ const row = n == null ? null : await h.maybe(sql`UPDATE ${t$} SET name = ${label} WHERE id = ${n} RETURNING *`);
++ if (!row) throw new PublishingError(404, 'term.not_found', `No term ${id}`);
++ return shape(row);
},
-- setParent(id, parentId) {
-- const term = mustGet(id);
-- if (parentId != null && Number(parentId) === term.id) throw new PublishingError(400, 'term.cycle', 'A term cannot be its own parent');
-- q.setParent.run(checkParent(term.id, parentId, term.vocabulary), term.id);
-- return api.get(id);
++ /** Re-parent a term. Tree changes of one taxonomy take turns, so two moves cannot make a cycle together. */
++ async setParent(h, id, parentId) {
++ return h.tx(async (t) => {
++ await lockTree(t);
++ const term = await mustGet(t, id);
++ if (parentId != null && Number(parentId) === term.id) throw new PublishingError(400, 'term.cycle', 'A term cannot be its own parent');
++ const pid = await checkParent(t, term.id, parentId, term.vocabulary);
++ return shape(await t.one(sql`UPDATE ${t$} SET parent_id = ${pid}::bigint WHERE id = ${term.id} RETURNING *`));
++ });
},
/** Root first, the term itself last: a breadcrumb trail. */
-- ancestors(id) { return q.ancestors.all(Number(id)).map(shape); },
-- descendants(id) { return q.descendants.all(Number(id)).map(shape); },
-- children(id) { return q.children.all(Number(id)).map(shape); },
++ async ancestors(h, id) { const n = toId(id); return n == null ? [] : (await h.many(ancestorsSql(n))).map(shape); },
++ async descendants(h, id, { limit = 1000 } = {}) {
++ const n = toId(id);
++ if (n == null) return [];
++ return (await h.many(sql`${descendantIds(n)}
++ SELECT t.* FROM down JOIN ${t$} t ON t.id = down.id WHERE down.depth > 0 ORDER BY t.name, t.id LIMIT ${boundedLimit(limit)}`)).map(shape);
++ },
++ async children(h, id, { limit = 1000 } = {}) {
++ const n = toId(id);
++ if (n == null) return [];
++ return (await h.many(sql`SELECT * FROM ${t$} WHERE parent_id = ${n} ORDER BY name, id LIMIT ${boundedLimit(limit)}`)).map(shape);
++ },
/** Nested tree of a vocabulary: [{ ...term, children: [...] }]. */
-- tree(vocabulary) {
-- const rows = q.vocab.all(vocabOf(vocabulary)).map((r) => ({ ...shape(r), children: [] }));
-- const byId = new Map(rows.map((r) => [r.id, r]));
++ async tree(h, vocabulary, { limit = 10000 } = {}) {
++ const rows = (await h.many(sql`SELECT * FROM ${t$} WHERE vocabulary = ${vocabOf(vocabulary)} ORDER BY name, id LIMIT ${boundedLimit(limit, 10000, 10000)}`))
++ .map((r) => ({ ...shape(r), children: [] }));
++ const nodes = new Map(rows.map((r) => [r.id, r]));
const roots = [];
-- for (const r of rows) (r.parentId != null && byId.has(r.parentId) ? byId.get(r.parentId).children : roots).push(r);
++ for (const r of rows) (r.parentId != null && nodes.has(r.parentId) ? nodes.get(r.parentId).children : roots).push(r);
return roots;
},
-- terms(vocabulary) { return q.vocab.all(vocabOf(vocabulary)).map(shape); },
++ /** Terms of a vocabulary by name. Keyset pages: `after` is the last term of the previous page. */
++ async terms(h, vocabulary, { limit = 1000, after = null } = {}) {
++ vocabOf(vocabulary);
++ let cursor = sql``;
++ if (after) {
++ const aid = toId(after.id);
++ if (aid == null || typeof after.name !== 'string') throw new TypeError('after must be a term from the previous page');
++ cursor = sql` AND (name, id) > (${after.name}, ${aid}::bigint)`;
++ }
++ return (await h.many(sql`SELECT * FROM ${t$} WHERE vocabulary = ${vocabulary}${cursor} ORDER BY name, id LIMIT ${boundedLimit(limit)}`)).map(shape);
++ },
-- /** Replace the entity's terms in one vocabulary. Items are term ids or names (created on demand). */
-- setTerms(entityId, vocabulary, items = []) {
++ /**
++ * Replace the entity's terms in one vocabulary. Items are term ids or names (created on
++ * demand). A fixed number of statements whatever the number of items.
++ */
++ async setTerms(h, entityId, vocabulary, items = []) {
assertEntityId(entityId);
vocabOf(vocabulary);
-- return db.transaction(() => {
-- q.unlinkVocab.run(entityId, vocabulary);
-- const t = clock();
++ // Validate everything before the first statement.
++ const wanted = items.map((item) => {
++ if (typeof item === 'number') return { id: item };
++ const label = cleanName(item);
++ return { slug: slugify(label), label };
++ });
++ return h.tx(async (t) => {
++ await lockKey(t, sql, `${L}:${entityId}:${vocabulary}`);
++ await t.exec(sql`DELETE FROM ${l$} WHERE entity_id = ${entityId} AND term_id IN (SELECT id FROM ${t$} WHERE vocabulary = ${vocabulary})`);
++ const ids = [...new Set(wanted.filter((w) => w.id != null).map((w) => w.id))];
++ const byNumber = new Map();
++ const validIds = ids.map(toId).filter((n) => n != null);
++ if (validIds.length) {
++ for (const r of await t.many(sql`SELECT * FROM ${t$} WHERE id = ANY(${validIds}::bigint[])`)) byNumber.set(r.id, r);
++ }
++ const named = new Map(); // slug → label of its first occurrence
++ for (const w of wanted) if (w.slug && !named.has(w.slug)) named.set(w.slug, w.label);
++ const bySlugs = new Map();
++ if (named.size) {
++ const slugs = [...named.keys()].sort(); // one insert order for every writer: no lock-order deadlock
++ await t.exec(sql`INSERT INTO ${t$} (vocabulary, slug, name, created_at)
++ SELECT ${vocabulary}, s, n, ${clock()}::bigint FROM unnest(${slugs}::text[], ${slugs.map((k) => named.get(k))}::text[]) AS x(s, n)
++ ON CONFLICT (vocabulary, slug) DO NOTHING`);
++ for (const r of await t.many(sql`SELECT * FROM ${t$} WHERE vocabulary = ${vocabulary} AND slug = ANY(${slugs}::text[])`)) bySlugs.set(r.slug, r);
++ }
++ const at = clock();
const seen = new Set();
-- items.forEach((item, i) => {
-- const term = typeof item === 'number' ? mustGet(item) : q.bySlug.get(vocabulary, slugify(item)) || q.byId.get(api.ensureTerm({ vocabulary, name: item }).id);
++ const links = [];
++ wanted.forEach((w, i) => {
++ const term = w.id != null ? byNumber.get(toId(w.id)) : bySlugs.get(w.slug);
++ if (!term) throw new PublishingError(404, 'term.not_found', `No term ${w.id}`);
if (term.vocabulary !== vocabulary) throw new PublishingError(400, 'term.vocabulary_mismatch', `Term ${term.id} is not a ${vocabulary}`);
if (seen.has(term.id)) return;
seen.add(term.id);
-- q.link.run(entityId, term.id, i, t);
++ links.push({ entity_id: entityId, term_id: term.id, position: i, created_at: at });
});
-- return api.termsFor(entityId, vocabulary);
-- })();
++ if (links.length) await t.exec(sql`INSERT INTO ${l$} ${sql.insert(links)} ON CONFLICT DO NOTHING`);
++ return termsFor(t, entityId, vocabulary, 10000);
++ });
},
-- termsFor(entityId, vocabulary) {
-- const rows = q.linksFor.all(assertEntityId(entityId));
-- return rows.filter((r) => !vocabulary || r.vocabulary === vocabulary).map(shape);
++ async termsFor(h, entityId, vocabulary, { limit = 1000 } = {}) {
++ return termsFor(h, assertEntityId(entityId), vocabulary || null, limit);
},
-- /** Entity ids tagged with the term (and, optionally, any of its descendants). */
-- entitiesFor(termId, { includeDescendants = false, limit = 1000 } = {}) {
-- const ids = [Number(termId), ...(includeDescendants ? q.descendants.all(Number(termId)).map((r) => r.id) : [])];
-- const marks = ids.map(() => '?').join(',');
-- return db.prepare(`SELECT DISTINCT entity_id FROM ${L} WHERE term_id IN (${marks}) ORDER BY entity_id LIMIT ?`)
-- .all(...ids, Math.max(1, Math.min(10000, limit))).map((r) => r.entity_id);
++ /** The terms of several entities in one query (a feed's tags): entityIds (up to 1000) → term lists, same order. */
++ async termsForMany(h, entityIds = [], vocabulary = null, { limit = 10000 } = {}) {
++ if (!Array.isArray(entityIds)) throw new TypeError('entityIds must be an array');
++ if (entityIds.length > 1000) throw new TypeError('at most 1000 entity ids per call');
++ const ids = [...new Set(entityIds.map((e) => assertEntityId(e)))];
++ const by = new Map();
++ if (ids.length) {
++ const only = vocabulary ? sql` AND t.vocabulary = ${vocabOf(vocabulary)}` : sql``;
++ const rows = await h.many(sql`SELECT t.*, l.entity_id AS linked_entity, l.position FROM ${l$} l JOIN ${t$} t ON t.id = l.term_id
++ WHERE l.entity_id = ANY(${ids}::text[])${only} ORDER BY l.entity_id, t.vocabulary, l.position, t.name LIMIT ${boundedLimit(limit, 10000, 10000)}`);
++ for (const r of rows) {
++ if (!by.has(r.linked_entity)) by.set(r.linked_entity, []);
++ by.get(r.linked_entity).push(shape(r));
++ }
++ }
++ return entityIds.map((e) => by.get(e) || []);
++ },
++
++ /**
++ * Entity ids tagged with the term (and, optionally, any of its descendants), in one query.
++ * Keyset pages: `after` is the last entity id of the previous page.
++ */
++ async entitiesFor(h, termId, { includeDescendants = false, limit = 1000, after = null } = {}) {
++ const n = toId(termId);
++ if (n == null) return [];
++ const cursor = after == null ? sql`` : sql` AND entity_id > ${String(after)}`;
++ const lim = boundedLimit(limit);
++ const rows = includeDescendants
++ ? await h.many(sql`${descendantIds(n)}
++ SELECT DISTINCT entity_id FROM ${l$} WHERE term_id IN (SELECT id FROM down)${cursor} ORDER BY entity_id LIMIT ${lim}`)
++ : await h.many(sql`SELECT entity_id FROM ${l$} WHERE term_id = ${n}${cursor} ORDER BY entity_id LIMIT ${lim}`);
++ return rows.map((r) => r.entity_id);
},
/** Remove a term: its children move up to its parent, its links are dropped. */
-- remove(id) {
-- const term = mustGet(id);
-- db.transaction(() => {
-- q.reparentChildren.run(term.parent_id, term.id);
-- q.unlinkTerm.run(term.id);
-- q.deleteTerm.run(term.id);
-- })();
++ async remove(h, id) {
++ await h.tx(async (t) => {
++ await lockTree(t);
++ const term = await mustGet(t, id);
++ await t.exec(sql`UPDATE ${t$} SET parent_id = ${term.parent_id}::bigint WHERE parent_id = ${term.id}`);
++ await t.exec(sql`DELETE FROM ${l$} WHERE term_id = ${term.id}`);
++ await t.exec(sql`DELETE FROM ${t$} WHERE id = ${term.id}`);
++ });
return true;
},
};
++
++ const api = bindHandles(db, impl);
++ api.tables = { terms: T, links: L };
++ api.schema = () => schema(prefix);
++ /** Create the tables where the handle may (tests, PGlite); services put schema() in a migration. */
++ api.ensureSchema = async () => { await db.query(schema(prefix)); return api; };
return api;
}
--module.exports = { createTaxonomy, slugify };
++module.exports = { createTaxonomy, schema, slugify };
ddiff --git a/package-lock.json b/package-lock.json
iindex d610896..f8c0c5e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,25 +1,27 @@
{
"name": "openvibe-publishing",
-- "version": "0.4.0",
++ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-publishing",
-- "version": "0.4.0",
++ "version": "1.0.0",
"license": "MIT",
"devDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
-- "better-sqlite3": "^11.10.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
++ "pg": "^8.23.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
-- "better-sqlite3": ">=11",
++ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
},
"peerDependenciesMeta": {
@@@ -28,6 +30,13 @@
}
}
},
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.8",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
++ "dev": true,
++ "license": "Apache-2.0"
++ },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@@ -63,86 +72,6 @@
}
}
},
-- "node_modules/base64-js": {
-- "version": "1.5.1",
-- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
-- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
-- "dev": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT"
-- },
-- "node_modules/better-sqlite3": {
-- "version": "11.10.0",
-- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
-- "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
-- "dev": true,
-- "hasInstallScript": true,
-- "license": "MIT",
-- "dependencies": {
-- "bindings": "^1.5.0",
-- "prebuild-install": "^7.1.1"
-- }
-- },
-- "node_modules/bindings": {
-- "version": "1.5.0",
-- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
-- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
-- "dev": true,
-- "license": "MIT",
-- "dependencies": {
-- "file-uri-to-path": "1.0.0"
-- }
-- },
-- "node_modules/bl": {
-- "version": "4.1.0",
-- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
-- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
-- "dev": true,
-- "license": "MIT",
-- "dependencies": {
-- "buffer": "^5.5.0",
-- "inherits": "^2.0.4",
-- "readable-stream": "^3.4.0"
-- }
-- },
-- "node_modules/buffer": {
-- "version": "5.7.1",
-- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
-- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
-- "dev": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "dependencies": {
-- "base64-js": "^1.3.1",
-- "ieee754": "^1.1.13"
-- }
-- },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@@ -150,49 +79,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
-- "node_modules/chownr": {
-- "version": "1.1.4",
-- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-- "dev": true,
-- "license": "ISC"
-- },
-- "node_modules/decompress-response": {
-- "version": "6.0.0",
-- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
-- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
-- "dev": true,
-- "license": "MIT",
-- "dependencies": {
-- "mimic-response": "^3.1.0"
-- },
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
-- "node_modules/deep-extend": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
-- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
-- "dev": true,
-- "license": "MIT",
-- "engines": {
-- "node": ">=4.0.0"
-- }
-- },
-- "node_modules/detect-libc": {
-- "version": "2.1.2",
-- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-- "dev": true,
-- "license": "Apache-2.0",
-- "engines": {
-- "node": ">=8"
-- }
-- },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@@ -203,26 +89,6 @@
"safe-buffer": "^5.0.1"
}
},
-- "node_modules/end-of-stream": {
-- "version": "1.4.5",
-- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
-- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
-- "dev": true,
-- "license": "MIT",
-- "dependencies": {
-- "once": "^1.4.0"
-- }
-- },
-- "node_modules/expand-template": {
-- "version": "2.0.3",
-- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
-- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
-- "dev": true,
-- "license": "(MIT OR WTFPL)",
-- "engines": {
-- "node": ">=6"
-- }
-- },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@@ -247,62 +113,6 @@
],
"license": "BSD-3-Clause"
},
-- "node_modules/file-uri-to-path": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
-- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
-- "dev": true,
-- "license": "MIT"
-- },
-- "node_modules/fs-constants": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
-- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
-- "dev": true,
-- "license": "MIT"
-- },
-- "node_modules/github-from-package": {
-- "version": "0.0.0",
-- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
-- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
-- "dev": true,
-- "license": "MIT"
-- },
-- "node_modules/ieee754": {
-- "version": "1.2.1",
-- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-- "dev": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "BSD-3-Clause"
-- },
-- "node_modules/inherits": {
-- "version": "2.0.4",
-- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-- "dev": true,
-- "license": "ISC"
-- },
-- "node_modules/ini": {
-- "version": "1.3.8",
-- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
-- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-- "dev": true,
-- "license": "ISC"
-- },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@@ -405,36 +215,6 @@
"dev": true,
"license": "MIT"
},
-- "node_modules/mimic-response": {
-- "version": "3.1.0",
-- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
-- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
-- "dev": true,
-- "license": "MIT",
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
-- "node_modules/minimist": {
-- "version": "1.2.8",
-- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-- "dev": true,
-- "license": "MIT",
-- "funding": {
-- "url": "https://github.com/sponsors/ljharb"
-- }
-- },
-- "node_modules/mkdirp-classic": {
-- "version": "0.5.3",
-- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
-- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
-- "dev": true,
-- "license": "MIT"
-- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@@ -442,36 +222,6 @@
"dev": true,
"license": "MIT"
},
-- "node_modules/napi-build-utils": {
-- "version": "2.0.0",
-- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
-- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
-- "dev": true,
-- "license": "MIT"
-- },
-- "node_modules/node-abi": {
-- "version": "3.96.0",
-- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
-- "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
-- "dev": true,
-- "license": "MIT",
-- "dependencies": {
-- "semver": "^7.3.5"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
-- "node_modules/once": {
-- "version": "1.4.0",
-- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-- "dev": true,
-- "license": "ISC",
-- "dependencies": {
-- "wrappy": "1"
-- }
-- },
"node_modules/openvibe-contracts": {
"version": "0.49.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
@@@ -489,6 +239,36 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-sdk": {
++ "version": "0.15.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
++ "integrity": "sha512-5ZcqEZxRQjT15erqtEdJ4js3roSijUFiQZPbGIHpaOVFRJSI45ZCEgTBb9ByKNRdUNBl4ub9Vb1dF2CcCVJjrg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "better-sqlite3": ">=11",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "better-sqlite3": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.25.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
@@@ -499,74 +279,144 @@
"jsonwebtoken": "^9.0.0"
}
},
-- "node_modules/prebuild-install": {
-- "version": "7.1.3",
-- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
-- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
-- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "node_modules/pg": {
++ "version": "8.23.0",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"dev": true,
"license": "MIT",
"dependencies": {
-- "detect-libc": "^2.0.0",
-- "expand-template": "^2.0.3",
-- "github-from-package": "0.0.0",
-- "minimist": "^1.2.3",
-- "mkdirp-classic": "^0.5.3",
-- "napi-build-utils": "^2.0.0",
-- "node-abi": "^3.3.0",
-- "pump": "^3.0.0",
-- "rc": "^1.2.7",
-- "simple-get": "^4.0.0",
-- "tar-fs": "^2.0.0",
-- "tunnel-agent": "^0.6.0"
++ "pg-connection-string": "^2.14.0",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.0",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
},
-- "bin": {
-- "prebuild-install": "bin.js"
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.0"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
},
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
++ "dev": true,
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.0",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "dev": true,
++ "license": "ISC",
"engines": {
-- "node": ">=10"
++ "node": ">=4.0.0"
}
},
-- "node_modules/pump": {
-- "version": "3.0.4",
-- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
-- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"dev": true,
"license": "MIT",
-- "dependencies": {
-- "end-of-stream": "^1.1.0",
-- "once": "^1.3.1"
++ "peerDependencies": {
++ "pg": ">=8.0"
}
},
-- "node_modules/rc": {
-- "version": "1.2.8",
-- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
-- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "node_modules/pg-protocol": {
++ "version": "1.16.0",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
-- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "license": "MIT",
"dependencies": {
-- "deep-extend": "^0.6.0",
-- "ini": "~1.3.0",
-- "minimist": "^1.2.0",
-- "strip-json-comments": "~2.0.1"
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
},
-- "bin": {
-- "rc": "cli.js"
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
}
},
-- "node_modules/readable-stream": {
-- "version": "3.6.2",
-- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
-- "inherits": "^2.0.3",
-- "string_decoder": "^1.1.1",
-- "util-deprecate": "^1.0.1"
++ "xtend": "^4.0.0"
},
"engines": {
-- "node": ">= 6"
++ "node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
@@@ -613,129 +463,25 @@
"node": ">=10"
}
},
-- "node_modules/simple-concat": {
-- "version": "1.0.1",
-- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
-- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
-- "dev": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT"
-- },
-- "node_modules/simple-get": {
-- "version": "4.0.1",
-- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
-- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
-- "dev": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "dependencies": {
-- "decompress-response": "^6.0.0",
-- "once": "^1.3.1",
-- "simple-concat": "^1.0.0"
-- }
-- },
-- "node_modules/string_decoder": {
-- "version": "1.3.0",
-- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
-- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
-- "license": "MIT",
-- "dependencies": {
-- "safe-buffer": "~5.2.0"
-- }
-- },
-- "node_modules/strip-json-comments": {
-- "version": "2.0.1",
-- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
-- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
-- "dev": true,
-- "license": "MIT",
++ "license": "ISC",
"engines": {
-- "node": ">=0.10.0"
++ "node": ">= 10.x"
}
},
-- "node_modules/tar-fs": {
-- "version": "2.1.5",
-- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
-- "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
-- "dependencies": {
-- "chownr": "^1.1.1",
-- "mkdirp-classic": "^0.5.2",
-- "pump": "^3.0.0",
-- "tar-stream": "^2.1.4"
-- }
-- },
-- "node_modules/tar-stream": {
-- "version": "2.2.0",
-- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
-- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
-- "dev": true,
-- "license": "MIT",
-- "dependencies": {
-- "bl": "^4.0.3",
-- "end-of-stream": "^1.4.1",
-- "fs-constants": "^1.0.0",
-- "inherits": "^2.0.3",
-- "readable-stream": "^3.1.1"
-- },
-- "engines": {
-- "node": ">=6"
-- }
-- },
-- "node_modules/tunnel-agent": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
-- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
-- "dev": true,
-- "license": "Apache-2.0",
-- "dependencies": {
-- "safe-buffer": "^5.0.1"
-- },
"engines": {
-- "node": "*"
++ "node": ">=0.4"
}
-- },
-- "node_modules/util-deprecate": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-- "dev": true,
-- "license": "MIT"
-- },
-- "node_modules/wrappy": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-- "dev": true,
-- "license": "ISC"
}
}
}
ddiff --git a/package.json b/package.json
iindex 425cf88..f1213d6 100644
---- a/package.json
++++ b/package.json
@@@ -1,7 +1,7 @@
{
"name": "openvibe-publishing",
-- "version": "0.4.0",
-- "description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
++ "version": "1.0.0",
++ "description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
"main": "index.js",
"exports": {
".": "./index.js",
@@@ -36,7 +36,7 @@
"homepage": "https://github.com/OpenVibers/OpenVibe.Publishing#readme",
"license": "MIT",
"peerDependencies": {
-- "better-sqlite3": ">=11",
++ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
},
"peerDependenciesMeta": {
@@@ -45,10 +45,12 @@
}
},
"devDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
-- "better-sqlite3": "^11.10.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
++ "pg": "^8.23.0"
}
}
ddiff --git a/test/authorship.test.js b/test/authorship.test.js
iindex 834c993..2b6933f 100644
---- a/test/authorship.test.js
++++ b/test/authorship.test.js
@@@ -1,6 +1,6 @@
'use strict';
const assert = require('assert');
--const { openDb, fakeClock, suite } = require('./helpers/db');
++const { openDb, fakeClock, suite, sql } = require('./helpers/db');
const authorship = require('../lib/authorship');
const seo = require('../lib/seo');
@@@ -23,25 +23,26 @@ test('records: ai/hybrid need a workflow run, human cannot carry one', () => {
assert.throws(() => authorship.record({ mode: 'human', authors: ['42'] }), /subject/);
});
--test('AI-generated defaults to draft + noindex until a person reviews it', () => {
-- const db = openDb('auth');
-- const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page', now: fakeClock() });
++test('AI-generated defaults to draft + noindex until a person reviews it', async () => {
++ const db = await openDb();
++ const reviews = await authorship.createReviewLog(db, { prefix: 'wiki_page', now: fakeClock() }).ensureSchema();
const rec = authorship.record({ mode: 'ai', workflow: WF });
assert.deepStrictEqual(authorship.initialState(rec), { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });
assert.deepStrictEqual(authorship.canPublish(rec), { ok: false, reason: 'ai_generated_unreviewed' });
-- const before = seo.evaluate(facts(authorship.gateFacts(rec, reviews.latest('pg', 1))));
++ const before = seo.evaluate(facts(authorship.gateFacts(rec, await reviews.latest('pg', 1))));
assert.deepStrictEqual(before.codes, ['ai_generated_unreviewed']);
-- assert.throws(() => reviews.record({ entityId: 'pg', revision: 1, reviewer: 'svc:wiki', decision: 'approved' }), (e) => e.code === 'review.reviewer_not_person');
-- reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'rejected', note: 'wrong facts' });
-- assert.strictEqual(authorship.canPublish(rec, reviews.latest('pg', 1)).ok, false);
-- reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'approved' });
-- const review = reviews.latest('pg', 1);
++ await assert.rejects(reviews.record({ entityId: 'pg', revision: 1, reviewer: 'svc:wiki', decision: 'approved' }), (e) => e.code === 'review.reviewer_not_person');
++ await reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'rejected', note: 'wrong facts' });
++ assert.strictEqual(authorship.canPublish(rec, await reviews.latest('pg', 1)).ok, false);
++ await reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'approved' });
++ const review = await reviews.latest('pg', 1);
assert.strictEqual(authorship.canPublish(rec, review).ok, true);
assert.strictEqual(seo.evaluate(facts(authorship.gateFacts(rec, review))).indexable, true);
-- assert.strictEqual(reviews.latest('pg', 2), null, 'a review covers one revision only');
-- assert.strictEqual(reviews.history('pg').length, 2);
-- assert.throws(() => db.prepare("UPDATE wiki_page_reviews SET decision = 'approved'").run(), /immutable/);
++ assert.strictEqual(await reviews.latest('pg', 2), null, 'a review covers one revision only');
++ assert.strictEqual((await reviews.history('pg')).length, 2);
++ assert.strictEqual(reviews.table, 'wiki_page_reviews');
++ await assert.rejects(db.exec(sql`UPDATE wiki_page_reviews SET decision = 'approved'`), (e) => /immutable/.test(e.message) && e.code === '23001');
});
test('stub-provider output is held like AI output', () => {
ddiff --git a/test/citations.test.js b/test/citations.test.js
iindex ac185df..e1cc37d 100644
---- a/test/citations.test.js
++++ b/test/citations.test.js
@@@ -1,39 +1,42 @@
'use strict';
const assert = require('assert');
--const { openDb, fakeClock, suite } = require('./helpers/db');
++const { openDb, countQueries, fakeClock, suite, sql } = require('./helpers/db');
const { createRevisionStore } = require('../lib/revisions');
const { createCitationStore, gateFacts } = require('../lib/citations');
const { test, run } = suite();
--function setup() {
-- const db = openDb('cite');
++async function setup() {
++ const db = await openDb();
const now = fakeClock();
-- const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
-- const cites = createCitationStore(db, { prefix: 'wiki', now, revisions });
-- revisions.create({ entityId: 'pg', expectedRevision: 0, content: 'Rye is a grass.' });
-- revisions.create({ entityId: 'pg', expectedRevision: 1, content: 'Rye is a grass grown as a grain.' });
-- revisions.create({ entityId: 'pg', expectedRevision: 2, content: 'Rye is a cereal.' });
++ const revisions = await createRevisionStore(db, { prefix: 'wiki_page', now }).ensureSchema();
++ const cites = await createCitationStore(db, { prefix: 'wiki', now, revisions }).ensureSchema();
++ await revisions.create({ entityId: 'pg', expectedRevision: 0, content: 'Rye is a grass.' });
++ await revisions.create({ entityId: 'pg', expectedRevision: 1, content: 'Rye is a grass grown as a grain.' });
++ await revisions.create({ entityId: 'pg', expectedRevision: 2, content: 'Rye is a cereal.' });
return { db, now, revisions, cites };
}
--test('a citation records source, retrieval time, quote span and license on one revision', () => {
-- const { cites } = setup();
-- const c = cites.attach({
++test('a citation records source, retrieval time, quote span and license on one revision', async () => {
++ const { cites } = await setup();
++ const c = await cites.attach({
entityId: 'pg', revision: 1, sourceItemId: 'src_01J8Z6Q3KX0000000000000001', url: 'https://example.org/rye',
title: 'Rye', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass', start: 0, end: 14 },
licenseNote: 'CC BY-SA 4.0', anchor: '1', attachedBy: 'usr_01J8Z6Q3KX0000000000000000',
});
assert.strictEqual(c.revision, 1);
++ assert.strictEqual(typeof c.id, 'number', 'bigint identity comes back as a Number');
assert.strictEqual(c.retrievedAt, '2026-09-20T10:00:00.000Z');
assert.deepStrictEqual(c.quote, { text: 'Rye is a grass', start: 0, end: 14 });
assert.strictEqual(c.licenseNote, 'CC BY-SA 4.0');
assert.strictEqual(cites.table, 'wiki_citations');
++ assert.deepStrictEqual(await cites.get(c.id), c);
++ assert.strictEqual(await cites.get('nope'), null);
});
--test('nothing is filled in: unknown retrieval time and title stay null', () => {
-- const { cites } = setup();
-- const c = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/x' });
++test('nothing is filled in: unknown retrieval time and title stay null', async () => {
++ const { cites } = await setup();
++ const c = await cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/x' });
assert.strictEqual(c.retrievedAt, null);
assert.strictEqual(c.title, null);
assert.strictEqual(c.licenseNote, null);
@@@ -41,44 +44,67 @@ test('nothing is filled in: unknown retrieval time and title stay null', () => {
assert.deepStrictEqual(gateFacts([c]), { citationCount: 1, datedCitationCount: 0 });
});
--test('citations stay attached to the revision that used them after later revisions drop them', () => {
-- const { cites } = setup();
-- const a = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
-- const b = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/b' });
-- const carried = cites.carryForward({ entityId: 'pg', fromRevision: 1, toRevision: 2, ids: [a.id] });
++test('citations stay attached to the revision that used them after later revisions drop them', async () => {
++ const { cites } = await setup();
++ const a = await cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
++ const b = await cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/b' });
++ const carried = await cites.carryForward({ entityId: 'pg', fromRevision: 1, toRevision: 2, ids: [a.id] });
assert.strictEqual(carried.length, 1);
assert.strictEqual(carried[0].carriedFrom, a.id);
++ assert.strictEqual(carried[0].revision, 2);
// revision 3 reuses nothing
-- assert.deepStrictEqual(cites.forRevision('pg', 3), []);
-- assert.deepStrictEqual(cites.forRevision('pg', 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
-- assert.deepStrictEqual(cites.forRevision('pg', 2).map((c) => c.url), ['https://example.org/a']);
-- assert.strictEqual(cites.history('pg').length, 3);
-- assert.ok(cites.get(b.id));
++ assert.deepStrictEqual(await cites.forRevision('pg', 3), []);
++ assert.deepStrictEqual((await cites.forRevision('pg', 1)).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
++ assert.deepStrictEqual((await cites.forRevision('pg', 2)).map((c) => c.url), ['https://example.org/a']);
++ assert.strictEqual((await cites.history('pg')).length, 3);
++ assert.ok(await cites.get(b.id));
++ // carrying a carried citation points at the original, not the copy
++ const again = await cites.carryForward({ entityId: 'pg', fromRevision: 2, toRevision: 3, attachedBy: 'usr_x' });
++ assert.strictEqual(again[0].carriedFrom, a.id);
++ assert.strictEqual(again[0].attachedBy, 'usr_x');
});
--test('citation rows cannot be updated or deleted outside an audited purge', () => {
-- const { db, cites } = setup();
-- cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
-- assert.throws(() => db.prepare("UPDATE wiki_citations SET url = 'https://evil.test'").run(), /immutable/);
-- assert.throws(() => db.prepare('DELETE FROM wiki_citations').run(), /never deleted/);
-- assert.deepStrictEqual(cites.purgeEntity('pg', { reason: 'legal' }), { deleted: 1 });
-- assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' }), (e) => e.status === 410);
++test('citation rows cannot be updated or deleted outside an audited purge', async () => {
++ const { db, cites } = await setup();
++ await cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
++ await assert.rejects(db.exec(sql`UPDATE wiki_citations SET url = 'https://evil.test'`), /immutable/);
++ await assert.rejects(db.exec(sql`DELETE FROM wiki_citations`), /never deleted/);
++ assert.deepStrictEqual(await cites.purgeEntity('pg', { reason: 'legal' }), { deleted: 1 });
++ await assert.rejects(cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' }), (e) => e.status === 410);
});
--test('validation: a source is required, URLs are http(s), revisions must exist, spans are ordered', () => {
-- const { cites } = setup();
-- assert.throws(() => cites.attach({ entityId: 'pg', revision: 1 }), (e) => e.code === 'citation.no_source');
-- assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'javascript:alert(1)' }), /http/);
-- assert.throws(() => cites.attach({ entityId: 'pg', revision: 9, url: 'https://example.org' }), (e) => e.code === 'revision.not_found');
-- assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', quote: { start: 5, end: 2 } }), /span/);
-- assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', retrievedAt: 'yesterday' }), /retrievedAt/);
++test('validation: a source is required, URLs are http(s), revisions must exist, spans are ordered', async () => {
++ const { cites } = await setup();
++ await assert.rejects(cites.attach({ entityId: 'pg', revision: 1 }), (e) => e.code === 'citation.no_source');
++ await assert.rejects(cites.attach({ entityId: 'pg', revision: 1, url: 'javascript:alert(1)' }), /http/);
++ await assert.rejects(cites.attach({ entityId: 'pg', revision: 9, url: 'https://example.org' }), (e) => e.code === 'revision.not_found');
++ await assert.rejects(cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', quote: { start: 5, end: 2 } }), /span/);
++ await assert.rejects(cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', retrievedAt: 'yesterday' }), /retrievedAt/);
++ await assert.rejects(cites.attachMany('pg', 1, [{ url: 'https://example.org/ok' }, { title: 'no source' }]), (e) => e.code === 'citation.no_source');
++ assert.deepStrictEqual(await cites.history('pg'), [], 'a refused batch writes nothing');
});
--test('bySourceItem finds every revision citing a source (correction propagation)', () => {
-- const { cites } = setup();
-- cites.attach({ entityId: 'pg', revision: 1, sourceItemId: 'src_A' });
-- cites.attach({ entityId: 'pg', revision: 3, sourceItemId: 'src_A' });
-- assert.deepStrictEqual(cites.bySourceItem('src_A').map((c) => c.revision), [1, 3]);
++test('bySourceItem finds every revision citing a source (correction propagation), in keyset pages', async () => {
++ const { cites, revisions } = await setup();
++ await cites.attach({ entityId: 'pg', revision: 1, sourceItemId: 'src_A' });
++ await cites.attach({ entityId: 'pg', revision: 3, sourceItemId: 'src_A' });
++ await revisions.create({ entityId: 'other', expectedRevision: 0, content: 'x' });
++ await cites.attach({ entityId: 'other', revision: 1, sourceItemId: 'src_A' });
++ assert.deepStrictEqual((await cites.bySourceItem('src_A')).map((c) => [c.entityId, c.revision]), [['other', 1], ['pg', 1], ['pg', 3]]);
++ const page1 = await cites.bySourceItem('src_A', { limit: 2 });
++ const page2 = await cites.bySourceItem('src_A', { limit: 2, after: page1[1] });
++ assert.deepStrictEqual([...page1, ...page2].map((c) => c.revision), [1, 1, 3]);
++});
++
++test('attachMany writes a batch in one INSERT: the statements do not grow with the batch', async () => {
++ const { db, cites } = await setup();
++ const few = await countQueries(db, () => cites.attachMany('pg', 1, [{ url: 'https://example.org/1' }]));
++ const many = await countQueries(db, () => cites.attachMany('pg', 2, Array.from({ length: 40 }, (_, i) => ({ url: `https://example.org/${i}` }))));
++ assert.strictEqual(many.count, few.count);
++ assert.deepStrictEqual(many.out.map((c) => c.url).slice(0, 3), ['https://example.org/0', 'https://example.org/1', 'https://example.org/2'], 'input order kept');
++ const carried = await countQueries(db, () => cites.carryForward({ entityId: 'pg', fromRevision: 2, toRevision: 3 }));
++ assert.strictEqual(carried.out.length, 40);
++ assert.ok(carried.count <= few.count, `carryForward is one INSERT … SELECT (${carried.count} statements)`);
});
run();
ddiff --git a/test/discussion.test.js b/test/discussion.test.js
iindex 540fd63..4cb4337 100644
---- a/test/discussion.test.js
++++ b/test/discussion.test.js
@@@ -2,7 +2,7 @@
const assert = require('assert');
const http = require('http');
const contracts = require('openvibe-contracts');
--const { openDb, fakeClock, suite } = require('./helpers/db');
++const { openDb, fakeClock, suite, sql } = require('./helpers/db');
const { createDiscussionClient, createDiscussionRefs, entityRef } = require('../lib/discussion');
const { test, run } = suite();
@@@ -75,8 +75,8 @@ test('failures are errors, never a fabricated thread', async () => {
test('the local table stores the reference only — no comment content columns — and resolves once', async () => {
const community = await fakeCommunity();
try {
-- const db = openDb('disc');
-- const refs = createDiscussionRefs(db, { prefix: 'wiki', now: fakeClock() });
++ const db = await openDb();
++ const refs = await createDiscussionRefs(db, { prefix: 'wiki', now: fakeClock() }).ensureSchema();
const client = createDiscussionClient({ communityUrl: community.url, tokenClient });
const ref = { service: 'wiki', type: 'page', id: 'pg_9' };
const a = await refs.threadFor('pg_9', ref, { client });
@@@ -85,9 +85,15 @@ test('the local table stores the reference only — no comment content columns
assert.strictEqual(b.cached, true);
assert.strictEqual(a.threadId, b.threadId);
assert.strictEqual(community.requests.length, 1);
-- const cols = db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort();
++ const cols = (await db.many(sql`SELECT column_name FROM information_schema.columns WHERE table_name = 'wiki_discussion_refs'`)).map((c) => c.column_name).sort();
assert.deepStrictEqual(cols, ['entity_id', 'ref', 'resolved_at', 'thread_id']);
-- assert.ok(contracts.validate('common.entity-ref@1', refs.get('pg_9').ref).valid);
++ const stored = await refs.get('pg_9');
++ assert.ok(contracts.validate('common.entity-ref@1', stored.ref).valid, 'jsonb comes back as the object');
++ assert.strictEqual(stored.resolvedAt, '2026-09-22T12:00:00.000Z');
++ assert.strictEqual((await refs.set('pg_9', 'thr_other', ref)).threadId, 'thr_other', 'set() replaces');
++ assert.strictEqual(await refs.forget('pg_9'), true);
++ assert.strictEqual(await refs.get('pg_9'), null);
++ assert.strictEqual(await refs.forget('pg_9'), false);
} finally { community.close(); }
});
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
iindex c5d9918..6cfca8e 100644
---- a/test/helpers/db.js
++++ b/test/helpers/db.js
@@@ -1,11 +1,20 @@
'use strict';
--/** A fresh on-disk SQLite database in a temp directory, removed at exit. */
++/**
++ * Test databases: real PostgreSQL in-process (PGlite, through openvibe-sdk/db), as ADR-035 asks.
++ *
++ * const db = await openDb(); // this file's database, emptied (a fresh public schema)
++ * const other = await newDb(); // a second, separate database (two products)
++ *
++ * Starting PGlite takes a second or two, so a test file shares one instance and each openDb()
++ * starts it over from an empty schema. suite().run() closes every database it opened.
++ */
const fs = require('fs');
const os = require('os');
const path = require('path');
--const Database = require('better-sqlite3');
++const { createDb, sql } = require('openvibe-sdk/db');
const dirs = [];
++const opened = [];
process.on('exit', () => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });
function tempDir(tag = 'pub') {
@@@ -14,13 +23,34 @@ function tempDir(tag = 'pub') {
return dir;
}
--function openDb(tag) {
-- const db = new Database(path.join(tempDir(tag), 'test.db'));
-- db.pragma('journal_mode = WAL');
-- db.pragma('foreign_keys = ON');
++/** A separate PGlite database (in memory, or persisted in `dir`). */
++async function newDb({ dir } = {}) {
++ const db = createDb({ pglite: dir || true, service: 'publishing-test' });
++ opened.push(db);
++ await db.value(sql`SELECT 1 AS ok`);
return db;
}
++/** Empty a database: drop and recreate the public schema (tables, indexes, trigger functions). */
++async function resetDb(db) {
++ await db.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;');
++ return db;
++}
++
++let shared = null;
++/** This file's database, emptied. */
++async function openDb() {
++ if (!shared) shared = newDb();
++ return resetDb(await shared);
++}
++
++/** Queries `fn` sends through `db` (the handle's own counter). */
++async function countQueries(db, fn) {
++ const before = db.stats().queries;
++ const out = await fn();
++ return { count: db.stats().queries - before, out };
++}
++
/** A controllable clock. */
function fakeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
let t = start;
@@@ -30,6 +60,10 @@ function fakeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
return now;
}
++async function closeAll() {
++ for (const db of opened.splice(0)) await db.close().catch(() => {});
++}
++
/** Tiny async-aware test runner: test(name, fn) then run(). */
function suite() {
const tests = [];
@@@ -40,10 +74,11 @@ function suite() {
for (const t of tests) {
try { await t.fn(); console.log(` ok ${t.name}`); } catch (err) { failed++; console.log(` FAIL ${t.name}\n${err && err.stack || err}`); }
}
++ await closeAll();
console.log(`${tests.length - failed}/${tests.length} passed`);
if (failed) process.exit(1);
},
};
}
--module.exports = { openDb, tempDir, fakeClock, suite };
++module.exports = { openDb, newDb, resetDb, tempDir, countQueries, fakeClock, suite, sql };
ddiff --git a/test/index-hooks.test.js b/test/index-hooks.test.js
iindex d0a2bb3..929ba56 100644
---- a/test/index-hooks.test.js
++++ b/test/index-hooks.test.js
@@@ -4,7 +4,7 @@ const contracts = require('openvibe-contracts');
const hooks = require('../lib/index-hooks');
const seo = require('../lib/seo');
const authorship = require('../lib/authorship');
--const { openDb, fakeClock, suite } = require('./helpers/db');
++const { openDb, fakeClock, suite, sql } = require('./helpers/db');
const { test, run } = suite();
const USER = 'usr_01J8Z6Q3KX0000000000000000';
@@@ -142,20 +142,43 @@ test('index events are what Search consumes: <owner>.index_document.upserted|del
assert.throws(() => hooks.indexEvent({}), /document/);
});
--test('the sequencer bumps the index revision on every indexed change and replays unchanged documents', () => {
-- const seq = hooks.createIndexSequencer(openDb('idx'), { prefix: 'wiki', now: fakeClock() });
-- const a = seq.stamp(hooks.buildIndexDocument(input({ revision: 0 })));
++test('the sequencer bumps the index revision on every indexed change and replays unchanged documents', async () => {
++ const db = await openDb();
++ const seq = await hooks.createIndexSequencer(db, { prefix: 'wiki', now: fakeClock() }).ensureSchema();
++ const a = await seq.stamp(db, hooks.buildIndexDocument(input({ revision: 0 })));
assert.strictEqual(a.revision, 1);
-- assert.strictEqual(seq.stamp(hooks.buildIndexDocument(input({ revision: 0 }))).revision, 1, 'same document, same revision');
-- const priv = seq.stamp(hooks.buildIndexDocument(input({ revision: 0, visibility: 'private', acl: { subjects: [USER] }, decision: decisionFor({ visibility: 'private' }) })));
++ assert.strictEqual((await seq.stamp(db, hooks.buildIndexDocument(input({ revision: 0 })))).revision, 1, 'same document, same revision');
++ const priv = await seq.stamp(db, hooks.buildIndexDocument(input({ revision: 0, visibility: 'private', acl: { subjects: [USER] }, decision: decisionFor({ visibility: 'private' }) })));
assert.strictEqual(priv.revision, 2, 'a visibility change with the same content still gets a new revision');
-- const gone = seq.stamp(hooks.tombstone({ owner: 'wiki', type: 'page', id: 'pg_1', revision: 0 }));
++ const gone = await seq.stamp(db, hooks.tombstone({ owner: 'wiki', type: 'page', id: 'pg_1', revision: 0 }));
assert.strictEqual(gone.revision, 3);
-- const back = seq.stamp(hooks.buildIndexDocument(input({ revision: 0 })));
++ const back = await seq.stamp(db, hooks.buildIndexDocument(input({ revision: 0 })));
assert.strictEqual(back.revision, 4, 'a restore outranks the tombstone');
-- assert.strictEqual(seq.current('wiki', 'page', 'pg_1'), 4);
++ assert.strictEqual(await seq.current('wiki', 'page', 'pg_1'), 4);
++ assert.strictEqual(await seq.current('wiki', 'page', 'nope'), null);
valid(back);
assert.strictEqual(seq.table, 'wiki_index_revisions');
++ // a higher revision asked for is kept; the counter never goes back
++ assert.strictEqual((await seq.stamp(db, hooks.buildIndexDocument(input({ revision: 10, title: 'Rye!' })))).revision, 10);
++ assert.strictEqual((await seq.stamp(db, hooks.buildIndexDocument(input({ revision: 2, title: 'Rye?' })))).revision, 11);
++});
++
++test('stamp() takes the transaction handle first: the revision commits with the event or not at all', async () => {
++ const db = await openDb();
++ const seq = await hooks.createIndexSequencer(db, { prefix: 'wiki', now: fakeClock() }).ensureSchema();
++ await db.query('CREATE TABLE wiki_outbox (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, envelope jsonb NOT NULL)');
++ await assert.rejects(seq.stamp(hooks.buildIndexDocument(input({ revision: 0 }))), /transaction handle/);
++ const send = (doc, fail) => db.tx(async (t) => {
++ const stamped = await seq.stamp(t, doc);
++ await t.exec(sql`INSERT INTO wiki_outbox (envelope) VALUES (${sql.json(hooks.indexEvent({ document: stamped }))})`);
++ if (fail) throw new Error('relay refused');
++ return stamped;
++ });
++ await assert.rejects(send(hooks.buildIndexDocument(input({ revision: 0 })), true), /relay refused/);
++ assert.strictEqual(await seq.current('wiki', 'page', 'pg_1'), null, 'rolled back with the event');
++ assert.strictEqual(await db.value(sql`SELECT count(*) FROM wiki_outbox`), 0);
++ assert.strictEqual((await send(hooks.buildIndexDocument(input({ revision: 0 })))).revision, 1);
++ assert.strictEqual(await db.value(sql`SELECT count(*) FROM wiki_outbox`), 1);
});
test('actionFor maps state transitions to product events', () => {
ddiff --git a/test/media.test.js b/test/media.test.js
iindex 372a068..a2378c0 100644
---- a/test/media.test.js
++++ b/test/media.test.js
@@@ -8,28 +8,40 @@ const { test, run } = suite();
const MED = 'med_01J8Z6Q3KX0000000000000000';
const MED2 = 'med_01J8Z6Q3KX0000000000000001';
--test('attachments reference Media ids and validate as Contracts MediaRef', () => {
-- const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });
-- const a = media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', alt: 'A loaf' });
++const store = async (prefix) => createAttachmentStore(await openDb(), { prefix, now: fakeClock() }).ensureSchema();
++
++test('attachments reference Media ids and validate as Contracts MediaRef', async () => {
++ const media = await store('blog_post');
++ const a = await media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', alt: 'A loaf' });
assert.strictEqual(a.state, 'unverified');
assert.strictEqual(a.broken, false);
assert.ok(contracts.validate('media.media-ref@1', mediaRef(a)).valid);
assert.ok(isMediaId('legacy:live:vod:123'));
-- assert.throws(() => media.attach({ entityId: 'post_1', mediaId: 'https://cdn.example/x.png' }), (e) => e.code === 'media.invalid_id');
++ await assert.rejects(media.attach({ entityId: 'post_1', mediaId: 'https://cdn.example/x.png' }), (e) => e.code === 'media.invalid_id');
assert.strictEqual(media.table, 'blog_post_attachments');
++ assert.deepStrictEqual(await media.get(a.id), a);
++ assert.strictEqual(await media.detach('post_2', a.id), false, 'detach names the entity');
++ assert.strictEqual(await media.detach('post_1', a.id), true);
++ assert.deepStrictEqual(await media.list('post_1'), []);
});
--test('a deleted Media object surfaces an explicit broken-asset state', () => {
-- const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });
-- media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', caption: 'Crumb' });
-- media.attach({ entityId: 'post_2', mediaId: MED });
-- media.attach({ entityId: 'post_2', mediaId: MED2 });
-- assert.deepStrictEqual(media.markBroken(MED, 'deleted'), ['post_1', 'post_2']);
-- const [att] = media.list('post_1');
++test('a deleted Media object surfaces an explicit broken-asset state', async () => {
++ const media = await store('blog_post');
++ await media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', caption: 'Crumb' });
++ await media.attach({ entityId: 'post_2', mediaId: MED });
++ await media.attach({ entityId: 'post_2', mediaId: MED2 });
++ await media.attach({ entityId: 'post_2', mediaId: MED, revision: 3 });
++ assert.deepStrictEqual(await media.entitiesUsing(MED), ['post_1', 'post_2']);
++ assert.deepStrictEqual(await media.entitiesUsing(MED, { after: 'post_1' }), ['post_2'], 'keyset page');
++ assert.deepStrictEqual(await media.markBroken(MED, 'deleted'), ['post_1', 'post_2']);
++ const [att] = await media.list('post_1');
assert.strictEqual(att.state, 'broken');
assert.strictEqual(att.brokenReason, 'deleted');
assert.ok(att.checkedAt);
-- assert.strictEqual(media.broken('post_2').length, 1);
++ assert.strictEqual((await media.broken('post_2')).length, 2);
++ assert.strictEqual((await media.list('post_2', { revision: 2 })).length, 2, 'entity-wide attachments, not revision 3\'s');
++ assert.strictEqual((await media.list('post_2', { revision: 3 })).length, 3);
++ await assert.rejects(media.markBroken(MED, 'gone'), /reason/);
const html = figureHtml(att, { urlFor: () => 'https://openvibe.media/o/x' });
assert.match(html, /data-state="broken"/);
assert.match(html, /no longer available/);
@@@ -38,16 +50,18 @@ test('a deleted Media object surfaces an explicit broken-asset state', () => {
});
test('verify(): 404 → broken, found → available, outage → unchanged (check_failed)', async () => {
-- const media = createAttachmentStore(openDb('media'), { prefix: 'wiki_page', now: fakeClock() });
-- media.attach({ entityId: 'pg', mediaId: MED });
-- media.attach({ entityId: 'pg', mediaId: MED2 });
++ const media = await store('wiki_page');
++ await media.attach({ entityId: 'pg', mediaId: MED });
++ await media.attach({ entityId: 'pg', mediaId: MED2 });
const out = await media.verify('pg', { resolve: async (id) => (id === MED ? { exists: true } : { exists: false, reason: 'not_found' }) });
assert.deepStrictEqual(out.map((o) => o.outcome), ['available', 'broken']);
const outage = await media.verify('pg', { resolve: async () => { throw new Error('ECONNREFUSED'); } });
assert.deepStrictEqual(outage.map((o) => o.outcome), ['check_failed', 'check_failed']);
-- assert.deepStrictEqual(media.list('pg').map((a) => a.state), ['available', 'broken'], 'an outage changes nothing');
-- media.markAvailable(MED2);
-- assert.strictEqual(media.list('pg')[1].state, 'available');
++ assert.deepStrictEqual((await media.list('pg')).map((a) => a.state), ['available', 'broken'], 'an outage changes nothing');
++ assert.strictEqual((await media.list('pg'))[1].brokenReason, 'not_found');
++ await media.markAvailable(MED2);
++ assert.strictEqual((await media.list('pg'))[1].state, 'available');
++ assert.strictEqual((await media.list('pg'))[1].brokenReason, null);
});
test('figureHtml escapes alt/caption and needs a URL builder for available media', () => {
ddiff --git a/test/package.test.js b/test/package.test.js
iindex 835da96..a630ac4 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -16,10 +16,11 @@ test('every subpath export resolves to an existing module', () => {
for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
});
--test('each module loads alone in a fresh process, without better-sqlite3 being required', () => {
++test('each module loads alone in a fresh process, without loading a database driver or the SDK', () => {
for (const s of SUBPATHS) {
const code = `const Module = require('module'); const orig = Module._load;
-- Module._load = function (req, ...rest) { if (req === 'better-sqlite3') throw new Error('loaded better-sqlite3'); return orig.call(this, req, ...rest); };
++ const banned = ['better-sqlite3', 'pg', '@electric-sql/pglite', 'openvibe-sdk/db'];
++ Module._load = function (req, ...rest) { if (banned.includes(req)) throw new Error('loaded ' + req); return orig.call(this, req, ...rest); };
require('openvibe-publishing/${s.slice(2)}'); console.log('ok');`;
const r = spawnSync(process.execPath, ['-e', code], { cwd: ROOT, encoding: 'utf8' });
assert.strictEqual(r.status, 0, `${s}: ${r.stderr}`);
@@@ -36,15 +37,22 @@ test('the root export is lazy and exposes every module', () => {
assert.strictEqual(out.before, 0);
assert.ok(out.after > 0);
assert.strictEqual(out.v, pkg.version);
-- assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);
++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'schema', 'seo', 'ssr', 'taxonomy', 'version']);
});
--test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {
++test('the package owns no runtime: no listen(), no database of its own, no env config in lib/', () => {
const fs = require('fs');
for (const f of fs.readdirSync(path.join(ROOT, 'lib'))) {
const src = fs.readFileSync(path.join(ROOT, 'lib', f), 'utf8');
-- assert.doesNotMatch(src, /\.listen\(|new Database\(|process\.env|require\('better-sqlite3'\)/, `${f} must take the consumer's handle, not open its own`);
++ assert.doesNotMatch(src, /\.listen\(|new Database\(|createDb\(|process\.env|require\('(better-sqlite3|pg|@electric-sql\/pglite)'\)/, `${f} must take the consumer's handle, not open its own`);
}
});
++test('better-sqlite3 is gone from the package; openvibe-sdk is a peer (one copy of the data layer per service)', () => {
++ for (const k of ['dependencies', 'peerDependencies', 'devDependencies', 'optionalDependencies']) assert.ok(!(pkg[k] || {})['better-sqlite3'], k);
++ assert.strictEqual(pkg.peerDependencies['openvibe-sdk'], '>=0.15.0');
++ assert.match(pkg.devDependencies['openvibe-sdk'], /refs\/tags\/v0\.15\.0$/);
++ assert.ok(!(pkg.dependencies || {})['openvibe-sdk']);
++});
++
run();
ddiff --git a/test/postgres.test.js b/test/postgres.test.js
nnew file mode 100644
iindex 0000000..f7119d0
---- /dev/null
++++ b/test/postgres.test.js
@@@ -0,0 +1,194 @@
++'use strict';
++/**
++ * Integration: the stores on PostgreSQL 18 through PgBouncer in transaction mode (ADR-035), the way a
++ * service runs them: DDL on the owner's direct connection (as its migration would), everything else
++ * through the pooler with several connections at once. PGlite is one connection, so what only real
++ * concurrency shows is here: racing writers of one entity, racing workers, racing get-or-create, a purge
++ * racing new revisions, and savepoints inside callers' transactions behind the pooler.
++ *
++ * Runs when OV_TEST_PG_URL (PgBouncer) and OV_TEST_PG_DIRECT_URL are set:
++ * eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test
++ * Tables are prefixed ovpubit_ and dropped before and after the run.
++ */
++const assert = require('assert');
++const { createDb, sql } = require('openvibe-sdk/db');
++const publishing = require('..');
++const { createRevisionStore } = require('../lib/revisions');
++const { createCitationStore } = require('../lib/citations');
++const { createTaxonomy } = require('../lib/taxonomy');
++const { createScheduler } = require('../lib/schedule');
++const { createAttachmentStore } = require('../lib/media');
++const { createReviewLog } = require('../lib/authorship');
++const { createRedirectStore } = require('../lib/seo');
++const { createDiscussionRefs } = require('../lib/discussion');
++const { createIndexSequencer, tombstone } = require('../lib/index-hooks');
++
++const LABEL = 'postgresql+pgbouncer';
++if (!process.env.OV_TEST_PG_URL || !process.env.OV_TEST_PG_DIRECT_URL) {
++ console.log(`${LABEL}: skipped (OV_TEST_PG_URL not set; start the containers with node_modules/openvibe-sdk/scripts/test-services.sh up)`);
++ process.exit(0);
++}
++
++const P = 'ovpubit';
++const MED = 'med_01J8Z6Q3KX0000000000000000';
++const USER = 'usr_01J8Z6Q3KX0000000000000000';
++const quiet = { warn() {}, error: console.error, log() {} };
++
++async function dropAll(owner) {
++ const tables = (await owner.many(sql`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE ${`${P}\\_%`}`)).map((r) => r.tablename);
++ if (tables.length) await owner.query(`DROP TABLE IF EXISTS ${tables.map((t) => `"${t}"`).join(', ')} CASCADE`);
++ const fns = (await owner.many(sql`SELECT proname FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND proname LIKE ${`${P}\\_%`}`)).map((r) => r.proname);
++ for (const f of fns) await owner.query(`DROP FUNCTION IF EXISTS "${f}"() CASCADE`);
++}
++
++const tests = [];
++const test = (name, fn) => tests.push({ name, fn });
++
++let owner;
++let db;
++let s;
++
++test('the schema applies on PostgreSQL 18 over the owner\'s direct connection (as a migration would)', async () => {
++ await dropAll(owner);
++ const text = publishing.schema({
++ revisions: `${P}_wiki_page`, citations: `${P}_wiki`, taxonomy: `${P}_blog`, schedule: `${P}_blog`, media: `${P}_blog_post`,
++ authorship: `${P}_wiki_page`, seo: `${P}_wiki_page`, discussion: `${P}_wiki`, indexHooks: `${P}_wiki`,
++ });
++ await owner.query(text);
++ await owner.query(text); // idempotent on the real server too
++ const version = await db.value(sql`SELECT current_setting('server_version_num')::int AS v`);
++ assert.ok(version >= 180000, `PostgreSQL ${version}`);
++ const r = await db.ready();
++ assert.strictEqual(r.detail.store, 'postgresql');
++});
++
++test('every store works through the pooler', async () => {
++ const { revisions, citations, taxonomy, scheduler, media, reviews, redirects, refs, seq } = s;
++ const r1 = (await revisions.create({ entityId: 'pg_1', expectedRevision: 0, content: 'Rye is a grass.', fields: { title: 'Rye' } })).revision;
++ await revisions.create({ entityId: 'pg_1', expectedRevision: 1, content: 'Rye is a cereal.', fields: { title: 'Rye' } });
++ assert.deepStrictEqual((await revisions.lineage('pg_1')).map((r) => r.number), [2, 1]);
++ assert.strictEqual((await revisions.diff('pg_1', 1, 2)).content.added, 1);
++ assert.deepStrictEqual(r1.fields, { title: 'Rye' });
++ await revisions.saveDraft({ entityId: 'pg_1', owner: USER, content: 'draft', baseRevision: 1 });
++ assert.strictEqual((await revisions.drafts('pg_1'))[0].stale, true);
++ const cites = await citations.attachMany('pg_1', 1, [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T10:00:00Z' }, { sourceItemId: 'src_1' }]);
++ assert.strictEqual(cites[0].retrievedAt, '2026-09-20T10:00:00.000Z');
++ await citations.carryForward({ entityId: 'pg_1', fromRevision: 1, toRevision: 2 });
++ assert.deepStrictEqual((await citations.forRevisions([{ entityId: 'pg_1', revision: 1 }, { entityId: 'pg_1', revision: 2 }])).map((l) => l.length), [2, 2]);
++ await taxonomy.setTerms('post_1', 'tag', ['Rye', 'Oats', 'rye']);
++ assert.deepStrictEqual((await taxonomy.termsFor('post_1', 'tag')).map((t) => t.slug), ['rye', 'oats']);
++ const job = await scheduler.schedule({ entityId: 'post_1', action: 'publish', runAt: Date.now() - 1000, revision: 1 });
++ assert.strictEqual((await scheduler.runDue({ worker: 'w', handler: () => ({ ok: true }) })).done[0].id, job.job.id);
++ await media.attach({ entityId: 'post_1', mediaId: MED });
++ assert.deepStrictEqual(await media.markBroken(MED, 'deleted'), ['post_1']);
++ await reviews.record({ entityId: 'pg_1', revision: 2, reviewer: USER, decision: 'approved' });
++ assert.strictEqual((await reviews.latestMany([{ entityId: 'pg_1', revision: 2 }]))[0].decision, 'approved');
++ await redirects.recordMove('pg_1', '/p/old', '/p/rye');
++ assert.strictEqual((await redirects.resolve('/p/old', { currentPath: async () => '/p/rye' })).status, 301);
++ await refs.set('pg_1', 'thr_1', { service: 'wiki', type: 'page', id: 'pg_1' });
++ assert.strictEqual((await refs.get('pg_1')).ref.id, 'pg_1');
++ const doc = tombstone({ owner: 'wiki', type: 'page', id: 'pg_1', revision: 0 });
++ assert.strictEqual((await db.tx((t) => seq.stamp(t, doc))).revision, 1);
++ assert.strictEqual((await db.tx((t) => seq.stamp(t, doc))).revision, 1);
++});
++
++test('immutability triggers hold on PostgreSQL 18', async () => {
++ await assert.rejects(db.exec(sql`UPDATE ${sql.ident(`${P}_wiki_page_revisions`)} SET content = 'x'`), (e) => e.code === '23001');
++ await assert.rejects(db.exec(sql`DELETE FROM ${sql.ident(`${P}_wiki_citations`)}`), (e) => e.code === '23001');
++ await assert.rejects(db.exec(sql`UPDATE ${sql.ident(`${P}_wiki_page_reviews`)} SET note = 'x'`), (e) => e.code === '23001');
++});
++
++test('racing writers of one entity (separate connections): exactly one revision lands, the rest are 412', async () => {
++ const { revisions } = s;
++ await revisions.create({ entityId: 'race', expectedRevision: 0, content: 'base' });
++ const results = await Promise.allSettled(Array.from({ length: 12 }, (_, i) => revisions.create({ entityId: 'race', expectedRevision: 1, content: `writer ${i}` })));
++ const ok = results.filter((r) => r.status === 'fulfilled');
++ assert.strictEqual(ok.length, 1, JSON.stringify(results.filter((r) => r.status === 'rejected').map((r) => r.reason.message).slice(0, 3)));
++ assert.ok(results.filter((r) => r.status === 'rejected').every((r) => r.reason.status === 412 && r.reason.current === 2));
++ assert.strictEqual(await revisions.headNumber('race'), 2);
++});
++
++test('a purge racing new revisions: nothing of the entity survives, and later writes are 410', async () => {
++ const { revisions } = s;
++ await revisions.create({ entityId: 'doomed', expectedRevision: 0, content: 'v1' });
++ const writes = Array.from({ length: 8 }, (_, i) => revisions.create({ entityId: 'doomed', expectedRevision: 1 + i, content: `v${i + 2}` }).catch((e) => e));
++ const purge = revisions.purgeEntity('doomed', { reason: 'legal' });
++ await Promise.all([...writes, purge]);
++ assert.strictEqual(await revisions.head('doomed'), null);
++ await assert.rejects(revisions.create({ entityId: 'doomed', expectedRevision: 0, content: 'again' }), (e) => e.status === 410);
++});
++
++test('racing workers (separate connections) never claim the same job', async () => {
++ const { scheduler } = s;
++ const runAt = Date.now() - 1000;
++ for (let i = 0; i < 30; i++) await scheduler.schedule({ entityId: `bulk_${i}`, action: 'publish', runAt, revision: 1 });
++ const seen = [];
++ await Promise.all(Array.from({ length: 6 }, (_, w) => scheduler.runDue({ worker: `w${w}`, limit: 10, handler: async (job) => { seen.push(job.id); await new Promise((r) => setTimeout(r, 5)); } })));
++ assert.strictEqual(seen.length, 30);
++ assert.strictEqual(new Set(seen).size, 30);
++});
++
++test('racing get-or-create and setTerms: one term per slug, and one writer\'s set wins whole', async () => {
++ const { taxonomy } = s;
++ const terms = await Promise.all(Array.from({ length: 8 }, () => taxonomy.ensureTerm({ vocabulary: 'tag', name: 'Spelt' })));
++ assert.strictEqual(new Set(terms.map((t) => t.id)).size, 1);
++ const sets = [['a', 'b'], ['c', 'd'], ['e', 'f'], ['g', 'h']];
++ await Promise.all(sets.map((set) => taxonomy.setTerms('post_race', 'tag', set)));
++ const final = (await taxonomy.termsFor('post_race', 'tag')).map((t) => t.slug);
++ assert.ok(sets.some((set) => JSON.stringify(set) === JSON.stringify(final)), `one set, not a mix: ${final}`);
++});
++
++test('a caller\'s transaction through the pooler: store writes and its own commit together, roll back together, and savepoints work', async () => {
++ const { revisions, citations, taxonomy } = s;
++ const T = sql.ident(`${P}_pages`);
++ await owner.query(`CREATE TABLE IF NOT EXISTS "${P}_pages" (id text PRIMARY KEY)`);
++ await assert.rejects(db.tx(async (t) => {
++ await t.exec(sql`INSERT INTO ${T} (id) VALUES ('tx_1')`);
++ await revisions.create(t, { entityId: 'tx_1', expectedRevision: 0, content: 'x' });
++ await citations.attachMany(t, 'tx_1', 1, [{ url: 'https://example.org/' }]);
++ throw new Error('abort');
++ }), /abort/);
++ assert.strictEqual(await db.value(sql`SELECT count(*) FROM ${T}`), 0);
++ assert.strictEqual(await revisions.head('tx_1'), null);
++ const n = await db.tx(async (t) => {
++ await t.exec(sql`INSERT INTO ${T} (id) VALUES ('tx_1')`);
++ await revisions.create(t, { entityId: 'tx_1', expectedRevision: 0, content: 'x' });
++ const stale = await revisions.create(t, { entityId: 'tx_1', expectedRevision: 0, content: 'y' }).catch((e) => e);
++ assert.strictEqual(stale.status, 412);
++ const refused = await taxonomy.setTerms(t, 'tx_1', 'tag', [99999999]).catch((e) => e);
++ assert.strictEqual(refused.code, 'term.not_found');
++ await citations.attachMany(t, 'tx_1', 1, [{ url: 'https://example.org/' }]);
++ return revisions.headNumber(t, 'tx_1');
++ });
++ assert.strictEqual(n, 1);
++ assert.strictEqual((await citations.forRevision('tx_1', 1)).length, 1);
++});
++
++(async () => {
++ owner = createDb({ url: process.env.OV_TEST_PG_DIRECT_URL, service: 'publishing-test', log: quiet });
++ db = createDb({ url: process.env.OV_TEST_PG_URL, service: 'publishing-test', max: 8, log: quiet });
++ const revisions = createRevisionStore(db, { prefix: `${P}_wiki_page` });
++ s = {
++ revisions,
++ citations: createCitationStore(db, { prefix: `${P}_wiki`, revisions }),
++ taxonomy: createTaxonomy(db, { prefix: `${P}_blog` }),
++ scheduler: createScheduler(db, { prefix: `${P}_blog` }),
++ media: createAttachmentStore(db, { prefix: `${P}_blog_post` }),
++ reviews: createReviewLog(db, { prefix: `${P}_wiki_page` }),
++ redirects: createRedirectStore(db, { prefix: `${P}_wiki_page` }),
++ refs: createDiscussionRefs(db, { prefix: `${P}_wiki` }),
++ seq: createIndexSequencer(db, { prefix: `${P}_wiki` }),
++ };
++ let failed = 0;
++ try {
++ for (const t of tests) {
++ try { await t.fn(); console.log(` ok ${LABEL}: ${t.name}`); } catch (err) { failed++; console.log(` FAIL ${LABEL}: ${t.name}\n${err && err.stack || err}`); }
++ }
++ } finally {
++ await dropAll(owner).catch(() => {});
++ await db.close();
++ await owner.close();
++ }
++ console.log(`${tests.length - failed}/${tests.length} passed`);
++ if (failed) process.exit(1);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/revisions.test.js b/test/revisions.test.js
iindex f61d1e7..4f467d4 100644
---- a/test/revisions.test.js
++++ b/test/revisions.test.js
@@@ -1,82 +1,96 @@
'use strict';
const assert = require('assert');
--const { openDb, fakeClock, suite } = require('./helpers/db');
--const { createRevisionStore, diffText } = require('../lib/revisions');
++const { openDb, countQueries, fakeClock, suite, sql } = require('./helpers/db');
++const { createRevisionStore, diffText, schema } = require('../lib/revisions');
const { formatLines } = require('../lib/diff');
const { test, run } = suite();
const USER = 'user:usr_01J8Z6Q3KX0000000000000000';
--test('create → edit → revert keeps an immutable lineage with parent pointers', () => {
-- const db = openDb('rev');
-- const revs = createRevisionStore(db, { prefix: 'wiki_page', now: fakeClock() });
-- const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: 'Bread is baked.\n', fields: { title: 'Bread' }, author: USER }).revision;
-- const r2 = revs.create({ entityId: 'pg_1', expectedRevision: 1, content: 'Bread is baked dough.\nIt is old.\n', fields: { title: 'Bread' }, author: USER }).revision;
++async function store(prefix, opts = {}) {
++ const db = opts.db || await openDb();
++ const revs = await createRevisionStore(db, { prefix, ...opts }).ensureSchema();
++ return { db, revs };
++}
++
++test('create → edit → revert keeps an immutable lineage with parent pointers', async () => {
++ const { revs } = await store('wiki_page', { now: fakeClock() });
++ const r1 = (await revs.create({ entityId: 'pg_1', expectedRevision: 0, content: 'Bread is baked.\n', fields: { title: 'Bread' }, author: USER })).revision;
++ const r2 = (await revs.create({ entityId: 'pg_1', expectedRevision: 1, content: 'Bread is baked dough.\nIt is old.\n', fields: { title: 'Bread' }, author: USER })).revision;
assert.strictEqual(r1.number, 1);
assert.strictEqual(r1.parentId, null);
assert.strictEqual(r2.parentId, r1.id);
assert.strictEqual(r2.parentNumber, 1);
-- const r3 = revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: USER }).revision;
++ const r3 = (await revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: USER })).revision;
assert.strictEqual(r3.number, 3);
assert.strictEqual(r3.kind, 'revert');
assert.strictEqual(r3.revertedTo, 1);
assert.strictEqual(r3.parentId, r2.id);
assert.strictEqual(r3.content, r1.content);
-- assert.deepStrictEqual(revs.lineage('pg_1').map((r) => r.number), [3, 2, 1]);
-- assert.strictEqual(revs.get('pg_1', 2).content, 'Bread is baked dough.\nIt is old.\n', 'revert did not rewrite history');
++ assert.deepStrictEqual(r3.meta, { revertedFrom: 2 });
++ assert.deepStrictEqual(r3.fields, { title: 'Bread' }, 'jsonb comes back parsed');
++ assert.deepStrictEqual((await revs.lineage('pg_1')).map((r) => r.number), [3, 2, 1]);
++ assert.deepStrictEqual((await revs.lineage('pg_1', 2)).map((r) => r.number), [2, 1]);
++ assert.deepStrictEqual((await revs.lineage('pg_1', null, { limit: 2 })).map((r) => r.number), [3, 2], 'lineage is bounded');
++ assert.strictEqual((await revs.get('pg_1', 2)).content, 'Bread is baked dough.\nIt is old.\n', 'revert did not rewrite history');
++ assert.strictEqual(await revs.exists('pg_1', 2), true);
++ assert.strictEqual(await revs.exists('pg_1', 9), false);
++ assert.strictEqual((await revs.getById(r2.id)).number, 2);
assert.deepStrictEqual(Object.keys(revs.tables), ['revisions', 'drafts', 'purges']);
assert.strictEqual(revs.tables.revisions, 'wiki_page_revisions');
++ assert.strictEqual(r1.createdAt, '2026-09-22T12:00:00.000Z', 'epoch milliseconds (bigint) round-trip');
});
--test('rows are immutable in SQLite itself (UPDATE and DELETE abort)', () => {
-- const db = openDb('rev');
-- const revs = createRevisionStore(db, { prefix: 'blog_post' });
-- revs.create({ entityId: 'p', expectedRevision: 0, content: 'x' });
-- assert.throws(() => db.prepare('UPDATE blog_post_revisions SET content = ?').run('y'), /immutable/);
-- assert.throws(() => db.prepare('DELETE FROM blog_post_revisions').run(), /never deleted/);
-- assert.strictEqual(revs.head('p').content, 'x');
++test('rows are immutable in PostgreSQL itself (UPDATE and DELETE abort in the PL/pgSQL trigger)', async () => {
++ const { db, revs } = await store('blog_post');
++ await revs.create({ entityId: 'p', expectedRevision: 0, content: 'x' });
++ await assert.rejects(db.exec(sql`UPDATE blog_post_revisions SET content = ${'y'}`), (e) => /immutable/.test(e.message) && e.code === '23001');
++ await assert.rejects(db.exec(sql`DELETE FROM blog_post_revisions`), /never deleted/);
++ assert.strictEqual((await revs.head('p')).content, 'x');
});
--test('optimistic concurrency: a stale expectedRevision is a 412 conflict', () => {
-- const db = openDb('rev');
-- const revs = createRevisionStore(db, { prefix: 'wiki_page' });
-- revs.create({ entityId: 'pg', expectedRevision: 0, content: 'a' });
-- revs.create({ entityId: 'pg', expectedRevision: 1, content: 'b' });
++test('optimistic concurrency: a stale expectedRevision is a 412 conflict', async () => {
++ const { revs } = await store('wiki_page');
++ await revs.create({ entityId: 'pg', expectedRevision: 0, content: 'a' });
++ await revs.create({ entityId: 'pg', expectedRevision: 1, content: 'b' });
let err;
-- try { revs.create({ entityId: 'pg', expectedRevision: 1, content: 'c' }); } catch (e) { err = e; }
++ try { await revs.create({ entityId: 'pg', expectedRevision: 1, content: 'c' }); } catch (e) { err = e; }
assert.ok(err, 'conflict thrown');
assert.strictEqual(err.status, 412);
assert.strictEqual(err.code, 'revision.conflict');
assert.strictEqual(err.expected, 1);
assert.strictEqual(err.current, 2);
-- assert.strictEqual(revs.headNumber('pg'), 2);
-- assert.throws(() => revs.create({ entityId: 'new', content: 'x' }), /expectedRevision/, 'expectedRevision is required');
++ assert.strictEqual(await revs.headNumber('pg'), 2);
++ await assert.rejects(revs.create({ entityId: 'new', content: 'x' }), /expectedRevision/, 'expectedRevision is required');
});
--test('a second connection on the same file with a stale base revision gets 412', () => {
-- const db = openDb('race');
-- const other = new (require('better-sqlite3'))(db.name);
-- const a = createRevisionStore(db, { prefix: 'wiki_page' });
-- const b = createRevisionStore(other, { prefix: 'wiki_page' });
-- a.create({ entityId: 'pg', expectedRevision: 0, content: 'base' });
-- a.create({ entityId: 'pg', expectedRevision: 1, content: 'from a' });
-- assert.throws(() => b.create({ entityId: 'pg', expectedRevision: 1, content: 'from b' }), (e) => e.status === 412);
-- other.close();
++test('a second store instance on the same database (another process) with a stale base revision gets 412', async () => {
++ const db = await openDb();
++ const a = await createRevisionStore(db, { prefix: 'wiki_page' }).ensureSchema();
++ const b = createRevisionStore(db, { prefix: 'wiki_page' });
++ await a.create({ entityId: 'pg', expectedRevision: 0, content: 'base' });
++ await a.create({ entityId: 'pg', expectedRevision: 1, content: 'from a' });
++ await assert.rejects(b.create({ entityId: 'pg', expectedRevision: 1, content: 'from b' }), (e) => e.status === 412);
++ // Concurrent writers of one entity with the same base: exactly one lands.
++ const results = await Promise.allSettled([2, 2, 2].map((n, i) => b.create({ entityId: 'pg', expectedRevision: n, content: `race ${i}` })));
++ assert.strictEqual(results.filter((r) => r.status === 'fulfilled').length, 1);
++ assert.ok(results.filter((r) => r.status === 'rejected').every((r) => r.reason.status === 412));
++ assert.strictEqual(await a.headNumber('pg'), 3);
});
--test('unchanged edits do not create empty revisions', () => {
-- const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });
-- revs.create({ entityId: 'e', expectedRevision: 0, content: 'same', fields: { a: 1, b: 2 } });
-- const again = revs.create({ entityId: 'e', expectedRevision: 1, content: 'same', fields: { b: 2, a: 1 } });
++test('unchanged edits do not create empty revisions', async () => {
++ const { revs } = await store('w');
++ await revs.create({ entityId: 'e', expectedRevision: 0, content: 'same', fields: { a: 1, b: 2 } });
++ const again = await revs.create({ entityId: 'e', expectedRevision: 1, content: 'same', fields: { b: 2, a: 1 } });
assert.strictEqual(again.created, false);
-- assert.strictEqual(revs.headNumber('e'), 1);
++ assert.strictEqual(await revs.headNumber('e'), 1);
});
--test('line and word diffs', () => {
-- const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });
-- revs.create({ entityId: 'e', expectedRevision: 0, content: 'one\ntwo\nthree\n', fields: { title: 'A' } });
-- revs.create({ entityId: 'e', expectedRevision: 1, content: 'one\n2\nthree\nfour\n', fields: { title: 'B', tag: 'x' } });
-- const d = revs.diff('e', 1, 2);
++test('line and word diffs', async () => {
++ const { revs } = await store('w');
++ await revs.create({ entityId: 'e', expectedRevision: 0, content: 'one\ntwo\nthree\n', fields: { title: 'A' } });
++ await revs.create({ entityId: 'e', expectedRevision: 1, content: 'one\n2\nthree\nfour\n', fields: { title: 'B', tag: 'x' } });
++ const d = await revs.diff('e', 1, 2);
assert.strictEqual(d.content.mode, 'line');
assert.deepStrictEqual(d.content.ops, [
{ op: 'equal', text: 'one\n' }, { op: 'delete', text: 'two\n' }, { op: 'insert', text: '2\n' },
@@@ -86,6 +100,7 @@ test('line and word diffs', () => {
assert.strictEqual(d.content.removed, 1);
assert.deepStrictEqual(d.fields, [{ field: 'tag', from: null, to: 'x' }, { field: 'title', from: 'A', to: 'B' }]);
assert.strictEqual(formatLines(d.content), ' one\n- two\n+ 2\n three\n+ four');
++ await assert.rejects(revs.diff('e', 1, 7), (e) => e.code === 'revision.not_found');
const w = diffText('The quick brown fox', 'The slow brown fox jumps', { mode: 'word' });
const rebuiltOld = w.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join('');
@@@ -118,44 +133,72 @@ test('a huge rewrite falls back to an approximate diff instead of exhausting mem
assert.strictEqual(r.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join(''), a);
});
--test('drafts: save, commit on the base revision, conflict keeps the draft', () => {
-- const revs = createRevisionStore(openDb('rev'), { prefix: 'blog_post', now: fakeClock() });
-- revs.create({ entityId: 'p1', expectedRevision: 0, content: 'v1' });
-- const d = revs.saveDraft({ entityId: 'p1', owner: USER, content: 'my edit' });
++test('drafts: save, commit on the base revision, conflict keeps the draft', async () => {
++ const { revs } = await store('blog_post', { now: fakeClock() });
++ await revs.create({ entityId: 'p1', expectedRevision: 0, content: 'v1' });
++ const d = await revs.saveDraft({ entityId: 'p1', owner: USER, content: 'my edit' });
assert.strictEqual(d.baseRevision, 1);
assert.strictEqual(d.stale, false);
-- revs.create({ entityId: 'p1', expectedRevision: 1, content: 'someone else' });
-- assert.strictEqual(revs.getDraft('p1', USER).stale, true);
-- assert.throws(() => revs.commitDraft({ entityId: 'p1', owner: USER }), (e) => e.status === 412);
-- assert.ok(revs.getDraft('p1', USER), 'draft survives a conflict');
-- revs.saveDraft({ entityId: 'p1', owner: USER, content: 'rebased edit', baseRevision: 2 });
-- const out = revs.commitDraft({ entityId: 'p1', owner: USER, message: 'rebased' });
++ await revs.create({ entityId: 'p1', expectedRevision: 1, content: 'someone else' });
++ assert.strictEqual((await revs.getDraft('p1', USER)).stale, true);
++ await assert.rejects(revs.commitDraft({ entityId: 'p1', owner: USER }), (e) => e.status === 412);
++ assert.ok(await revs.getDraft('p1', USER), 'draft survives a conflict');
++ await revs.saveDraft({ entityId: 'p1', owner: USER, content: 'rebased edit', baseRevision: 2 });
++ const out = await revs.commitDraft({ entityId: 'p1', owner: USER, message: 'rebased' });
assert.strictEqual(out.revision.number, 3);
assert.strictEqual(out.revision.author, USER);
-- assert.strictEqual(revs.getDraft('p1', USER), null);
++ assert.strictEqual(await revs.getDraft('p1', USER), null);
++ await assert.rejects(revs.commitDraft({ entityId: 'p1', owner: USER }), (e) => e.code === 'draft.not_found');
++ assert.strictEqual(await revs.discardDraft('p1', USER), false);
++});
++
++test('drafts() answers in one query however many drafts there are, with each one\'s staleness', async () => {
++ const { db, revs } = await store('wiki_page', { now: fakeClock() });
++ await revs.create({ entityId: 'pg', expectedRevision: 0, content: 'v1' });
++ for (let i = 0; i < 6; i++) await revs.saveDraft({ entityId: 'pg', owner: `user:usr_${i}`, content: `edit ${i}`, baseRevision: i % 2 ? 0 : 1 });
++ const { count, out } = await countQueries(db, () => revs.drafts('pg'));
++ assert.strictEqual(count, 1, 'no N+1: one query for every draft and the head');
++ assert.strictEqual(out.length, 6);
++ assert.deepStrictEqual(out.map((d) => d.stale).sort(), [false, false, false, true, true, true]);
++ const listed = await countQueries(db, () => revs.list('pg'));
++ assert.strictEqual(listed.count, 1);
++});
++
++test('list() pages with a keyset cursor, newest first', async () => {
++ const { revs } = await store('w');
++ for (let i = 0; i < 7; i++) await revs.create({ entityId: 'e', expectedRevision: i, content: `v${i}` });
++ const page1 = await revs.list('e', { limit: 3 });
++ assert.deepStrictEqual(page1.map((r) => r.number), [7, 6, 5]);
++ const page2 = await revs.list('e', { limit: 3, before: page1[2].number });
++ assert.deepStrictEqual(page2.map((r) => r.number), [4, 3, 2]);
++ assert.deepStrictEqual((await revs.list('e', { before: 2 })).map((r) => r.number), [1]);
});
--test('purge is explicit, audited and final', () => {
-- const db = openDb('rev');
-- const revs = createRevisionStore(db, { prefix: 'w' });
-- revs.create({ entityId: 'gone', expectedRevision: 0, content: 'x' });
-- revs.create({ entityId: 'kept', expectedRevision: 0, content: 'y' });
-- assert.throws(() => revs.purgeEntity('gone'), /reason/);
-- assert.deepStrictEqual(revs.purgeEntity('gone', { reason: 'legal request #12', purgedBy: USER }), { deleted: 1 });
-- assert.strictEqual(revs.head('gone'), null);
-- assert.ok(revs.head('kept'));
-- assert.strictEqual(db.prepare('SELECT reason FROM w_revision_purges WHERE entity_id = ?').get('gone').reason, 'legal request #12');
-- assert.throws(() => revs.create({ entityId: 'gone', expectedRevision: 0, content: 'again' }), (e) => e.status === 410);
-- assert.throws(() => db.prepare("DELETE FROM w_revisions WHERE entity_id = 'kept'").run(), /never deleted/);
++test('purge is explicit, audited and final', async () => {
++ const { db, revs } = await store('w');
++ await revs.create({ entityId: 'gone', expectedRevision: 0, content: 'x' });
++ await revs.create({ entityId: 'kept', expectedRevision: 0, content: 'y' });
++ await revs.saveDraft({ entityId: 'gone', owner: USER, content: 'draft' });
++ await assert.rejects(revs.purgeEntity('gone'), /reason/);
++ assert.deepStrictEqual(await revs.purgeEntity('gone', { reason: 'legal request #12', purgedBy: USER }), { deleted: 1 });
++ assert.strictEqual(await revs.head('gone'), null);
++ assert.strictEqual(await revs.getDraft('gone', USER), null);
++ assert.ok(await revs.head('kept'));
++ assert.strictEqual(await db.value(sql`SELECT reason FROM w_revision_purges WHERE entity_id = ${'gone'}`), 'legal request #12');
++ await assert.rejects(revs.create({ entityId: 'gone', expectedRevision: 0, content: 'again' }), (e) => e.status === 410);
++ await assert.rejects(db.exec(sql`DELETE FROM w_revisions WHERE entity_id = 'kept'`), /never deleted/);
});
--test('prefix and handle are validated; schema creation is idempotent', () => {
-- const db = openDb('rev');
++test('prefix and handle are validated; schema creation is idempotent', async () => {
++ const db = await openDb();
assert.throws(() => createRevisionStore(db, { prefix: 'Bad-Prefix' }), /prefix/);
assert.throws(() => createRevisionStore(db, { prefix: 'x; DROP TABLE y' }), /prefix/);
-- assert.throws(() => createRevisionStore({}, { prefix: 'ok' }), /better-sqlite3/);
-- createRevisionStore(db, { prefix: 'twice' });
-- createRevisionStore(db, { prefix: 'twice' });
++ assert.throws(() => schema('x; DROP TABLE y'), /prefix/);
++ assert.throws(() => createRevisionStore({}, { prefix: 'ok' }), /openvibe-sdk\/db/);
++ assert.throws(() => createRevisionStore({ prepare() {}, transaction() {} }, { prefix: 'ok' }), /better-sqlite3 handles are 0\.4\.x/);
++ await createRevisionStore(db, { prefix: 'twice' }).ensureSchema();
++ await createRevisionStore(db, { prefix: 'twice' }).ensureSchema();
++ assert.strictEqual(createRevisionStore(db, { prefix: 'twice' }).schema(), schema('twice'));
});
run();
ddiff --git a/test/schedule.test.js b/test/schedule.test.js
iindex 724258f..2e1034a 100644
---- a/test/schedule.test.js
++++ b/test/schedule.test.js
@@@ -1,27 +1,30 @@
'use strict';
const assert = require('assert');
--const Database = require('better-sqlite3');
--const { openDb, fakeClock, suite } = require('./helpers/db');
++const { openDb, fakeClock, suite, sql } = require('./helpers/db');
const { createScheduler } = require('../lib/schedule');
const { test, run } = suite();
const T0 = Date.parse('2026-10-01T09:00:00Z');
--test('schedule() is idempotent for the same job', () => {
-- const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0 - 60000) });
-- const a = s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 3 });
-- const b = s.schedule({ entityId: 'post_1', action: 'publish', runAt: new Date(T0).toISOString(), revision: 3 });
++const scheduler = async (opts) => createScheduler(opts.db || await openDb(), { prefix: 'blog', ...opts }).ensureSchema();
++
++test('schedule() is idempotent for the same job', async () => {
++ const s = await scheduler({ now: fakeClock(T0 - 60000) });
++ const a = await s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 3 });
++ const b = await s.schedule({ entityId: 'post_1', action: 'publish', runAt: new Date(T0).toISOString(), revision: 3 });
assert.strictEqual(a.created, true);
assert.strictEqual(b.created, false);
assert.strictEqual(a.job.id, b.job.id);
-- assert.strictEqual(s.jobs('post_1').length, 1);
++ assert.strictEqual((await s.jobs('post_1')).length, 1);
assert.strictEqual(s.table, 'blog_schedule_jobs');
++ assert.strictEqual(a.job.runAt, '2026-10-01T09:00:00.000Z');
++ assert.strictEqual(a.job.key, 'post_1:publish:1790845200000:3');
});
test('jobs are not due before runAt, and run once when due', async () => {
const clock = fakeClock(T0 - 60000);
-- const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock });
-- s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 2 });
++ const s = await scheduler({ now: clock });
++ await s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 2 });
let calls = 0;
const handler = () => { calls++; return { published: 2 }; };
assert.deepStrictEqual((await s.runDue({ worker: 'w1', handler })).done, []);
@@@ -35,78 +38,96 @@ test('jobs are not due before runAt, and run once when due', async () => {
test('a worker that dies mid-job: the lease expires and a restarted worker re-runs it; the effect happens once', async () => {
const clock = fakeClock(T0);
-- const db = openDb('sch');
-- const s = createScheduler(db, { prefix: 'blog', now: clock, leaseMs: 30000 });
++ const db = await openDb();
++ const s = await scheduler({ db, now: clock, leaseMs: 30000 });
// the product's own state, updated idempotently by the handler
-- db.exec('CREATE TABLE posts (id TEXT PRIMARY KEY, published_revision INTEGER, publish_count INTEGER NOT NULL DEFAULT 0)');
-- db.prepare("INSERT INTO posts (id) VALUES ('post_1')").run();
-- const handler = (job) => {
-- const info = db.prepare('UPDATE posts SET published_revision = ?, publish_count = publish_count + 1 WHERE id = ? AND published_revision IS NOT ?').run(job.revision, job.entityId, job.revision);
-- return { changed: info.changes };
++ await db.query('CREATE TABLE posts (id text PRIMARY KEY, published_revision integer, publish_count integer NOT NULL DEFAULT 0)');
++ await db.exec(sql`INSERT INTO posts (id) VALUES ('post_1')`);
++ const handler = async (job) => {
++ const changed = await db.exec(sql`UPDATE posts SET published_revision = ${job.revision}, publish_count = publish_count + 1
++ WHERE id = ${job.entityId} AND published_revision IS DISTINCT FROM ${job.revision}::integer`);
++ return { changed };
};
-- s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 4 });
++ await s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 4 });
// worker A claims, applies the effect, then "crashes" before complete()
-- const [claimed] = s.claim({ worker: 'A' });
-- handler(claimed);
-- assert.strictEqual(s.get(claimed.id).status, 'running');
++ const [claimed] = await s.claim({ worker: 'A' });
++ await handler(claimed);
++ assert.strictEqual((await s.get(claimed.id)).status, 'running');
-- // restart: a new scheduler instance on a new connection (like a new process)
-- const s2 = createScheduler(new Database(db.name), { prefix: 'blog', now: clock, leaseMs: 30000 });
-- assert.strictEqual(s2.claim({ worker: 'B' }).length, 0, 'lease still held');
++ // restart: a new scheduler instance (like a new process on the same database)
++ const s2 = createScheduler(db, { prefix: 'blog', now: clock, leaseMs: 30000 });
++ assert.strictEqual((await s2.claim({ worker: 'B' })).length, 0, 'lease still held');
clock.advance(30001);
const out = await s2.runDue({ worker: 'B', handler });
assert.strictEqual(out.done.length, 1);
assert.strictEqual(out.done[0].attempts, 2);
assert.deepStrictEqual(out.done[0].result, { changed: 0 });
-- const post = db.prepare("SELECT * FROM posts WHERE id = 'post_1'").get();
++ const post = await db.one(sql`SELECT * FROM posts WHERE id = 'post_1'`);
assert.strictEqual(post.published_revision, 4);
assert.strictEqual(post.publish_count, 1, 'published exactly once');
// the crashed worker coming back cannot overwrite the result
-- assert.strictEqual(s.complete(claimed.id, 'A', { late: true }), false);
-- assert.deepStrictEqual(s.get(claimed.id).result, { changed: 0 });
++ assert.strictEqual(await s.complete(claimed.id, 'A', { late: true }), false);
++ assert.deepStrictEqual((await s.get(claimed.id)).result, { changed: 0 });
++});
++
++test('concurrent claims never take the same job twice', async () => {
++ const s = await scheduler({ now: fakeClock(T0) });
++ for (let i = 0; i < 12; i++) await s.schedule({ entityId: `p${i}`, action: 'publish', runAt: T0 - i, revision: 1 });
++ const claims = await Promise.all(['a', 'b', 'c', 'd'].map((worker) => s.claim({ worker, limit: 5 })));
++ const ids = claims.flat().map((j) => j.id);
++ assert.strictEqual(ids.length, 12);
++ assert.strictEqual(new Set(ids).size, 12);
++ assert.deepStrictEqual(claims[0].map((j) => j.entityId), ['p11', 'p10', 'p9', 'p8', 'p7'], 'oldest runAt first');
});
test('failures retry with backoff, then fail for good', async () => {
const clock = fakeClock(T0);
-- const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, backoffMs: [1000] });
-- s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 });
++ const s = await scheduler({ now: clock, maxAttempts: 2, backoffMs: [1000] });
++ await s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 });
const boom = () => { throw new Error('media service down'); };
let out = await s.runDue({ worker: 'w', handler: boom });
assert.strictEqual(out.retried.length, 1);
assert.strictEqual(out.retried[0].lastError, 'media service down');
++ assert.strictEqual(out.retried[0].runAt, new Date(T0 + 1000).toISOString(), 'backoff applied');
assert.strictEqual((await s.runDue({ worker: 'w', handler: boom })).retried.length, 0, 'waits for backoff');
clock.advance(1000);
out = await s.runDue({ worker: 'w', handler: boom });
assert.strictEqual(out.failed.length, 1);
assert.strictEqual(out.failed[0].status, 'failed');
++ assert.deepStrictEqual(await s.fail(out.failed[0].id, 'w', 'again'), { status: 'lost' });
});
test('a job whose workers keep crashing is abandoned after maxAttempts and reported as failed', async () => {
const clock = fakeClock(T0);
-- const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, leaseMs: 1000 });
-- s.schedule({ entityId: 'p', action: 'publish', runAt: T0, revision: 1 });
-- s.claim({ worker: 'a' }); clock.advance(1001);
-- s.claim({ worker: 'b' }); clock.advance(1001);
++ const s = await scheduler({ now: clock, maxAttempts: 2, leaseMs: 1000 });
++ await s.schedule({ entityId: 'p', action: 'publish', runAt: T0, revision: 1 });
++ await s.claim({ worker: 'a' }); clock.advance(1001);
++ await s.claim({ worker: 'b' }); clock.advance(1001);
const out = await s.runDue({ worker: 'c', handler: () => assert.fail('must not run a third time') });
assert.strictEqual(out.failed.length, 1);
assert.match(out.failed[0].lastError, /lease expired/);
++ assert.strictEqual(out.failed[0].attempts, 2);
});
--test('cancel and cancelPending', () => {
-- const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0) });
-- const { job } = s.schedule({ entityId: 'p', action: 'publish', runAt: T0 + 1000, revision: 1 });
-- s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 + 5000 });
-- assert.strictEqual(s.cancel(job.id), true);
-- assert.strictEqual(s.cancelPending('p', 'unpublish'), 1);
-- assert.deepStrictEqual(s.jobs('p').map((j) => j.status), ['cancelled', 'cancelled']);
++test('cancel and cancelPending', async () => {
++ const s = await scheduler({ now: fakeClock(T0) });
++ const { job } = await s.schedule({ entityId: 'p', action: 'publish', runAt: T0 + 1000, revision: 1 });
++ await s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 + 5000 });
++ await s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 + 9000 });
++ assert.strictEqual(await s.cancel(job.id), true);
++ assert.strictEqual(await s.cancel(job.id), false, 'only a pending job');
++ assert.strictEqual(await s.cancelPending('p', 'unpublish'), 2);
++ assert.deepStrictEqual((await s.jobs('p')).map((j) => j.status), ['cancelled', 'cancelled', 'cancelled']);
++ assert.strictEqual(await s.cancelPending('p'), 0);
});
--test('validation', () => {
-- const s = createScheduler(openDb('sch'), { prefix: 'blog' });
-- assert.throws(() => s.schedule({ entityId: 'p', action: 'delete', runAt: T0 }), /action/);
-- assert.throws(() => s.schedule({ entityId: 'p', action: 'publish', runAt: 'soon' }), /runAt/);
-- assert.throws(() => s.claim({}), /worker/);
++test('validation', async () => {
++ const s = await scheduler({});
++ await assert.rejects(s.schedule({ entityId: 'p', action: 'delete', runAt: T0 }), /action/);
++ await assert.rejects(s.schedule({ entityId: 'p', action: 'publish', runAt: 'soon' }), /runAt/);
++ await assert.rejects(s.claim({}), /worker/);
++ await assert.rejects(s.runDue({ worker: 'w' }), /handler/);
});
run();
ddiff --git a/test/schema.test.js b/test/schema.test.js
nnew file mode 100644
iindex 0000000..4a2e19e
---- /dev/null
++++ b/test/schema.test.js
@@@ -0,0 +1,233 @@
++'use strict';
++/**
++ * schema(prefix) (1.0.0): each storing module gives its DDL as text for the service's migration; the
++ * text is idempotent, every identifier fits PostgreSQL's 63 bytes at the longest prefix, it applies
++ * through db.migrate() as a service's 0001_initial.sql, the types are PostgreSQL's (bigint identity,
++ * jsonb, bigint ms, timestamptz), the append-only guards are PL/pgSQL triggers, and every query the
++ * stores send is served by an index (checked by EXPLAIN with sequential scans switched off).
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { openDb, tempDir, fakeClock, suite, sql } = require('./helpers/db');
++const publishing = require('..');
++const { createRevisionStore } = require('../lib/revisions');
++const { createCitationStore } = require('../lib/citations');
++const { createTaxonomy } = require('../lib/taxonomy');
++const { createScheduler } = require('../lib/schedule');
++const { createAttachmentStore } = require('../lib/media');
++const { createReviewLog } = require('../lib/authorship');
++const { createRedirectStore } = require('../lib/seo');
++const { createDiscussionRefs } = require('../lib/discussion');
++const { createIndexSequencer, tombstone } = require('../lib/index-hooks');
++
++const { test, run } = suite();
++const MODULES = ['revisions', 'citations', 'media', 'discussion', 'schedule', 'authorship', 'taxonomy', 'seo', 'indexHooks'];
++const LONGEST = `p${'x'.repeat(39)}`; // PREFIX_RE allows 40 characters
++const all = (prefix) => publishing.schema(Object.fromEntries(MODULES.map((m) => [m, prefix])));
++
++test('every storing module exports its DDL; the root schema() joins them', () => {
++ assert.strictEqual(typeof require('../lib/revisions').schema, 'function');
++ for (const m of ['citations', 'media', 'discussion', 'schedule', 'authorship', 'taxonomy']) assert.strictEqual(typeof require(`../lib/${m}`).schema, 'function', m);
++ assert.strictEqual(typeof require('../lib/seo').redirectsSchema, 'function');
++ assert.strictEqual(typeof require('../lib/index-hooks').sequencerSchema, 'function');
++ const text = publishing.schema({ revisions: ['wiki_page', 'wiki_talk'], seo: 'wiki_page' });
++ assert.match(text, /-- openvibe-publishing\/revisions \(prefix wiki_page\)/);
++ assert.match(text, /CREATE TABLE IF NOT EXISTS wiki_talk_revisions/);
++ assert.match(text, /CREATE TABLE IF NOT EXISTS wiki_page_redirects/);
++ assert.throws(() => publishing.schema({ ssr: 'x' }), /no store in module "ssr"/);
++ assert.throws(() => publishing.schema({ revisions: 'Bad Prefix' }), /prefix/);
++ assert.strictEqual(require('../lib/media').schema('blog_post'), createAttachmentStore({ query() {}, tx() {}, many() {}, maybe() {}, exec() {}, sql }, { prefix: 'blog_post' }).schema());
++});
++
++test('every identifier fits in 63 bytes at the longest prefix, and none is reused', async () => {
++ const text = all(LONGEST);
++ const names = [...text.matchAll(/CREATE (?:UNIQUE )?(?:TABLE|INDEX) IF NOT EXISTS (\w+)|FUNCTION (\w+)\(|TRIGGER (\w+) /g)].map((m) => m[1] || m[2] || m[3]);
++ assert.ok(names.length > 30, `found ${names.length} names`);
++ for (const n of names) assert.ok(Buffer.byteLength(n) <= 63, `${n} is ${Buffer.byteLength(n)} bytes`);
++ const tablesAndIndexes = [...text.matchAll(/CREATE (?:UNIQUE )?(?:TABLE|INDEX) IF NOT EXISTS (\w+)/g)].map((m) => m[1]);
++ assert.strictEqual(new Set(tablesAndIndexes).size, tablesAndIndexes.length, 'no two objects share a name');
++ const db = await openDb();
++ await db.query(text);
++ const made = (await db.many(sql`SELECT relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND relkind IN ('r', 'i')`)).map((r) => r.relname);
++ for (const n of tablesAndIndexes) assert.ok(made.includes(n), `${n} exists under its full name`);
++});
++
++test('the DDL is idempotent and applies as a service migration (db.migrate), once', async () => {
++ const db = await openDb();
++ await db.query(all('wiki'));
++ await db.query(all('wiki')); // twice: IF NOT EXISTS / OR REPLACE everywhere
++ const dir = tempDir('migrations');
++ fs.writeFileSync(path.join(dir, '0001_initial.sql'), `-- phase: expand\n${all('blog')}`);
++ const quiet = { log() {} };
++ assert.deepStrictEqual((await db.migrate({ dir, log: quiet })).applied.map((m) => m.id), ['0001']);
++ assert.deepStrictEqual((await db.migrate({ dir, log: quiet })).applied, []);
++ assert.ok(await db.value(sql`SELECT to_regclass('blog_revisions') IS NOT NULL AS ok`));
++});
++
++test('PostgreSQL types: bigint identities, jsonb, bigint epoch ms, timestamptz for datetime text', async () => {
++ const db = await openDb();
++ await db.query(all('t'));
++ const cols = await db.many(sql`SELECT table_name, column_name, data_type, is_identity, identity_generation, collation_name
++ FROM information_schema.columns WHERE table_schema = 'public'`);
++ const col = (t, c) => cols.find((r) => r.table_name === t && r.column_name === c);
++ for (const t of ['t_citations', 't_attachments', 't_reviews', 't_terms']) {
++ assert.deepStrictEqual([col(t, 'id').data_type, col(t, 'id').is_identity, col(t, 'id').identity_generation], ['bigint', 'YES', 'ALWAYS'], t);
++ }
++ for (const [t, c] of [['t_revisions', 'fields'], ['t_revisions', 'meta'], ['t_drafts', 'fields'], ['t_discussion_refs', 'ref'], ['t_schedule_jobs', 'result']]) {
++ assert.strictEqual(col(t, c).data_type, 'jsonb', `${t}.${c}`);
++ }
++ for (const [t, c] of [['t_revisions', 'created_at'], ['t_schedule_jobs', 'run_at'], ['t_schedule_jobs', 'lease_until'], ['t_attachments', 'checked_at'], ['t_redirects', 'created_at']]) {
++ assert.strictEqual(col(t, c).data_type, 'bigint', `${t}.${c} stays epoch milliseconds`);
++ }
++ assert.strictEqual(col('t_citations', 'retrieved_at').data_type, 'timestamp with time zone');
++ assert.strictEqual(col('t_revisions', 'entity_id').collation_name, 'C', 'identifiers compare in byte order, as SQLite did');
++ const checks = (await db.many(sql`SELECT conname, pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE contype = 'c'`)).map((r) => r.def).join('\n');
++ for (const needle of ["'edit'::text, 'revert'::text, 'import'::text", "'publish'::text, 'unpublish'::text", "'approved'::text, 'rejected'::text",
++ "'unverified'::text, 'available'::text, 'broken'::text", "(state = 'broken'::text) = (broken_reason IS NOT NULL)", 'source_item_id IS NOT NULL) OR (url IS NOT NULL)', 'number >= 1']) {
++ assert.ok(checks.includes(needle), `CHECK kept: ${needle}`);
++ }
++});
++
++test('immutability triggers: UPDATE always aborts; DELETE only after a recorded purge; reviews refuse UPDATE only', async () => {
++ const db = await openDb();
++ const revs = await createRevisionStore(db, { prefix: 'w' }).ensureSchema();
++ const cites = await createCitationStore(db, { prefix: 'w', revisions: revs }).ensureSchema();
++ const reviews = await createReviewLog(db, { prefix: 'w' }).ensureSchema();
++ await revs.create({ entityId: 'e', expectedRevision: 0, content: 'x' });
++ await cites.attach({ entityId: 'e', revision: 1, url: 'https://example.org/' });
++ await reviews.record({ entityId: 'e', revision: 1, reviewer: 'usr_01J8Z6Q3KX0000000000000000', decision: 'approved' });
++ const triggers = (await db.many(sql`SELECT tgname FROM pg_trigger WHERE NOT tgisinternal ORDER BY tgname`)).map((r) => r.tgname);
++ assert.deepStrictEqual(triggers, ['w_citations_no_delete', 'w_citations_no_update', 'w_reviews_no_update', 'w_revisions_no_delete', 'w_revisions_no_update']);
++ for (const table of ['w_revisions', 'w_citations']) {
++ await assert.rejects(db.exec(sql`UPDATE ${sql.ident(table)} SET entity_id = entity_id`), (e) => e.code === '23001' && /immutable/.test(e.message), table);
++ await assert.rejects(db.exec(sql`DELETE FROM ${sql.ident(table)} WHERE entity_id = 'e'`), (e) => e.code === '23001' && /never deleted outside a recorded purge/.test(e.message), table);
++ }
++ // Registering the purge (what purgeEntity does first) is what allows the DELETE; UPDATE stays refused.
++ await db.exec(sql`INSERT INTO w_revision_purges (entity_id, reason, purged_at) VALUES ('e', 'test', 0)`);
++ await assert.rejects(db.exec(sql`UPDATE w_revisions SET content = 'changed'`), /immutable/);
++ assert.strictEqual(await db.exec(sql`DELETE FROM w_revisions WHERE entity_id = 'e'`), 1);
++ await assert.rejects(db.exec(sql`DELETE FROM w_citations WHERE entity_id = 'e'`), /never deleted/, 'each table checks its own purge record');
++ await assert.rejects(db.exec(sql`UPDATE w_reviews SET note = 'x'`), /immutable/);
++ assert.strictEqual(await db.exec(sql`DELETE FROM w_reviews WHERE entity_id = 'e'`), 1, 'reviews: only UPDATE is refused, as in 0.4');
++ // The guards hold inside a transaction too, and the failed statement leaves the table as it was.
++ await assert.rejects(db.tx(async (t) => { await t.exec(sql`UPDATE w_citations SET title = 'x'`); }), /immutable/);
++ assert.strictEqual(await db.value(sql`SELECT count(*) FROM w_citations WHERE title IS NULL`), 1);
++});
++
++/** A handle that records every statement a store sends (the text and values), for EXPLAIN. */
++function recording(h, log) {
++ const rec = (q, values) => log.push(typeof q === 'string' ? { text: q, values: values || [] } : q.compile());
++ const w = { sql: h.sql };
++ for (const m of ['query', 'many', 'maybe', 'one', 'value', 'exec']) w[m] = (q, v) => { rec(q, v); return h[m](q, v); };
++ w.tx = (fn, opts) => h.tx((t) => fn(recording(t, log)), opts);
++ return w;
++}
++
++/**
++ * Scans of the stores' tables that nothing narrows: a sequential scan, or (with sequential scans off,
++ * the planner's fallback) a whole-index scan with neither an Index Cond nor a partial index's predicate.
++ * On empty tables the planner may prefer a tiny partial index whose predicate the query repeats
++ * (status = 'pending'): that is narrowed by the predicate, so it counts as indexed.
++ */
++function unindexed(node, partial, out = []) {
++ if (!node) return out;
++ const type = node['Node Type'];
++ const rel = node['Relation Name'] || node['Index Name'] || '';
++ if (rel.startsWith('x_')) {
++ if (type === 'Seq Scan') out.push(`Seq Scan on ${rel}`);
++ if (/Index Scan|Index Only Scan|Bitmap Index Scan/.test(type) && !node['Index Cond'] && !partial.has(node['Index Name'])) {
++ out.push(`${type} without a condition on ${node['Index Name']}`);
++ }
++ }
++ for (const child of node.Plans || []) unindexed(child, partial, out);
++ return out;
++}
++
++test('an index serves every query the stores send (EXPLAIN with sequential scans off finds none)', async () => {
++ const db = await openDb();
++ const log = [];
++ const h = recording(db, log);
++ const now = fakeClock();
++ const revisions = await createRevisionStore(h, { prefix: 'x', now }).ensureSchema();
++ const citations = await createCitationStore(h, { prefix: 'x', now, revisions }).ensureSchema();
++ const taxonomy = await createTaxonomy(h, { prefix: 'x', now }).ensureSchema();
++ const scheduler = await createScheduler(h, { prefix: 'x', now, maxAttempts: 1, leaseMs: 10 }).ensureSchema();
++ const media = await createAttachmentStore(h, { prefix: 'x', now }).ensureSchema();
++ const reviews = await createReviewLog(h, { prefix: 'x', now }).ensureSchema();
++ const redirects = await createRedirectStore(h, { prefix: 'x', now }).ensureSchema();
++ const refs = await createDiscussionRefs(h, { prefix: 'x', now }).ensureSchema();
++ const seq = await createIndexSequencer(h, { prefix: 'x', now }).ensureSchema();
++ log.length = 0;
++
++ // A workload that sends every query shape of every store.
++ const U = 'usr_01J8Z6Q3KX0000000000000000';
++ const MED = 'med_01J8Z6Q3KX0000000000000000';
++ await revisions.create({ entityId: 'e', expectedRevision: 0, content: 'a', fields: { t: 1 } });
++ await revisions.create({ entityId: 'e', expectedRevision: 1, content: 'b' });
++ await revisions.revert({ entityId: 'e', toRevision: 1, expectedRevision: 2 });
++ await revisions.head('e'); await revisions.headNumber('e'); await revisions.get('e', 1); await revisions.exists('e', 1);
++ await revisions.getById('nope'); await revisions.list('e'); await revisions.list('e', { before: 2 }); await revisions.lineage('e'); await revisions.lineage('e', 2);
++ await revisions.getMany([{ entityId: 'e', revision: 1 }]); await revisions.diff('e', 1, 2);
++ await revisions.saveDraft({ entityId: 'e', owner: U, content: 'd' }); await revisions.getDraft('e', U); await revisions.drafts('e');
++ await revisions.commitDraft({ entityId: 'e', owner: U }).catch(() => {}); await revisions.discardDraft('e', U);
++ const c = await citations.attach({ entityId: 'e', revision: 1, url: 'https://example.org/a', sourceItemId: 'src_1' });
++ await citations.attachMany('e', 2, [{ url: 'https://example.org/b' }]);
++ await citations.carryForward({ entityId: 'e', fromRevision: 1, toRevision: 2, ids: [c.id] });
++ await citations.get(c.id); await citations.forRevision('e', 1); await citations.forRevisions([{ entityId: 'e', revision: 1 }]); await citations.history('e');
++ await citations.bySourceItem('src_1'); await citations.bySourceItem('src_1', { after: c });
++ const food = await taxonomy.ensureTerm({ vocabulary: 'category', name: 'Food' });
++ const bread = await taxonomy.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: food.id });
++ await taxonomy.setParent(bread.id, food.id); await taxonomy.rename(bread.id, 'Breads'); await taxonomy.get(food.id); await taxonomy.bySlug('category', 'food');
++ await taxonomy.ancestors(bread.id); await taxonomy.descendants(food.id); await taxonomy.children(food.id); await taxonomy.tree('category');
++ await taxonomy.terms('category'); await taxonomy.terms('category', { after: food });
++ await taxonomy.setTerms('e', 'tag', ['rye', 'oats']); await taxonomy.setTerms('e', 'category', [bread.id]);
++ await taxonomy.termsFor('e'); await taxonomy.termsFor('e', 'tag'); await taxonomy.termsForMany(['e'], 'tag');
++ await taxonomy.entitiesFor(food.id); await taxonomy.entitiesFor(food.id, { includeDescendants: true, after: 'a' }); await taxonomy.remove(bread.id);
++ const { job } = await scheduler.schedule({ entityId: 'e', action: 'publish', runAt: now(), revision: 1 });
++ await scheduler.schedule({ entityId: 'e', action: 'publish', runAt: now(), revision: 1 });
++ await scheduler.get(job.id); await scheduler.jobs('e');
++ await scheduler.runDue({ worker: 'w', handler: () => { throw new Error('x'); } });
++ await scheduler.schedule({ entityId: 'e', action: 'unpublish', runAt: now() });
++ await scheduler.claim({ worker: 'w' }); now.advance(20); await scheduler.claim({ worker: 'w2' });
++ await scheduler.runDue({ worker: 'w3', handler: () => ({ ok: true }) }); await scheduler.complete(job.id, 'w', null);
++ await scheduler.cancel(job.id); await scheduler.cancelPending('e'); await scheduler.cancelPending('e', 'publish');
++ const a = await media.attach({ entityId: 'e', mediaId: MED });
++ await media.get(a.id); await media.list('e'); await media.list('e', { revision: 1 }); await media.broken('e');
++ await media.entitiesUsing(MED); await media.entitiesUsing(MED, { after: 'a' }); await media.markBroken(MED); await media.markAvailable(MED);
++ await media.verify('e', { resolve: async () => ({ exists: true }) }); await media.detach('e', a.id);
++ await reviews.record({ entityId: 'e', revision: 1, reviewer: U, decision: 'approved' });
++ await reviews.latest('e', 1); await reviews.latestMany([{ entityId: 'e', revision: 1 }]); await reviews.history('e');
++ await redirects.recordMove('e', '/a', '/b'); await redirects.recordMove('e', '/b', '/b'); await redirects.release('/a');
++ await redirects.resolve('/a', { currentPath: () => '/b' }); await redirects.history('e');
++ await refs.set('e', 'thr_1', { service: 'wiki', type: 'page', id: 'e' }); await refs.get('e'); await refs.forget('e');
++ await seq.stamp(h, tombstone({ owner: 'wiki', type: 'page', id: 'e', revision: 0 })); await seq.stamp(h, tombstone({ owner: 'wiki', type: 'page', id: 'e', revision: 0 }));
++ await seq.current('wiki', 'page', 'e');
++ await citations.purgeEntity('e', { reason: 'test' }); await revisions.purgeEntity('e', { reason: 'test' });
++
++ const shapes = new Map();
++ for (const q of log) {
++ const text = q.text.trim();
++ if (!/^(SELECT|UPDATE|DELETE|WITH|INSERT)/i.test(text) || /pg_advisory_xact_lock/.test(text)) continue;
++ if (/^INSERT/i.test(text) && !/SELECT|ON CONFLICT/i.test(text)) continue;
++ if (!shapes.has(text)) shapes.set(text, q.values);
++ }
++ assert.ok(shapes.size > 60, `${shapes.size} query shapes recorded`);
++ const offenders = [];
++ const partial = new Set((await db.many(sql`SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexdef LIKE '% WHERE %'`)).map((r) => r.indexname));
++ assert.deepStrictEqual([...partial].sort(), ['x_citations_carried', 'x_citations_source', 'x_schedule_jobs_due', 'x_schedule_jobs_lease']);
++ await db.tx(async (t) => {
++ await t.query('SET LOCAL enable_seqscan = off');
++ // The check itself: a filter on a column without an index is caught.
++ const control = await t.value('EXPLAIN (FORMAT JSON) SELECT * FROM x_revisions WHERE author = $1', ['a']);
++ assert.strictEqual(unindexed(control[0].Plan, partial).length, 1, JSON.stringify(control[0].Plan));
++ for (const [text, values] of shapes) {
++ const plan = await t.value(`EXPLAIN (FORMAT JSON) ${text}`, values);
++ const scans = unindexed(plan[0].Plan, partial);
++ if (scans.length) offenders.push(`${scans.join(', ')}: ${text.replace(/\s+/g, ' ').slice(0, 200)}`);
++ }
++ });
++ assert.deepStrictEqual(offenders, [], `queries without an index:\n${offenders.join('\n')}`);
++});
++
++run();
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex f756f9c..9f912a5 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -102,26 +102,31 @@ test('canonicalUrl', () => {
assert.throws(() => seo.canonicalUrl('ftp://x'), /http/);
});
--test('history-aware redirects: every old slug 301s straight to the current path; gone → 410', () => {
-- const redirects = seo.createRedirectStore(openDb('seo'), { prefix: 'wiki_page', now: fakeClock() });
++test('history-aware redirects: every old slug 301s straight to the current path; gone → 410', async () => {
++ const redirects = await seo.createRedirectStore(await openDb(), { prefix: 'wiki_page', now: fakeClock() }).ensureSchema();
const current = { pg_1: '/p/rye' };
const currentPath = (id) => current[id] || null;
-- redirects.recordMove('pg_1', '/p/ryegrass', '/p/rye-grain');
-- redirects.recordMove('pg_1', '/p/rye-grain', '/p/rye');
-- assert.deepStrictEqual(redirects.resolve('/p/ryegrass', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
-- assert.deepStrictEqual(redirects.resolve('/p/rye-grain/?x=1', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
-- assert.strictEqual(redirects.resolve('/p/rye', { currentPath }), null);
-- assert.strictEqual(redirects.resolve('/p/unknown', { currentPath }), null);
++ await redirects.recordMove('pg_1', '/p/ryegrass', '/p/rye-grain');
++ await redirects.recordMove('pg_1', '/p/rye-grain', '/p/rye');
++ assert.deepStrictEqual(await redirects.resolve('/p/ryegrass', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
++ assert.deepStrictEqual(await redirects.resolve('/p/rye-grain/?x=1', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
++ assert.strictEqual(await redirects.resolve('/p/rye', { currentPath }), null);
++ assert.strictEqual(await redirects.resolve('/p/unknown', { currentPath }), null);
// renamed back to an old slug: that slug is live again and must not redirect
-- redirects.recordMove('pg_1', '/p/rye', '/p/ryegrass');
++ await redirects.recordMove('pg_1', '/p/rye', '/p/ryegrass');
current.pg_1 = '/p/ryegrass';
-- assert.strictEqual(redirects.resolve('/p/ryegrass', { currentPath }), null);
-- assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }).location, '/p/ryegrass');
-- assert.deepStrictEqual(redirects.history('pg_1').map((h) => h.path).sort(), ['/p/rye', '/p/rye-grain']);
++ assert.strictEqual(await redirects.resolve('/p/ryegrass', { currentPath }), null);
++ assert.deepStrictEqual((await redirects.resolve('/p/rye', { currentPath })).location, '/p/ryegrass');
++ assert.deepStrictEqual((await redirects.history('pg_1')).map((h) => h.path).sort(), ['/p/rye', '/p/rye-grain']);
++ // currentPath may be async (the product's own lookup)
++ assert.deepStrictEqual((await redirects.resolve('/p/rye', { currentPath: async (id) => current[id] })).location, '/p/ryegrass');
delete current.pg_1;
-- assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }), { status: 410, entityId: 'pg_1' });
++ assert.deepStrictEqual(await redirects.resolve('/p/rye', { currentPath }), { status: 410, entityId: 'pg_1' });
assert.strictEqual(redirects.table, 'wiki_page_redirects');
-- assert.throws(() => redirects.recordMove('pg_1', 'no-slash', '/x'), /start with/);
++ await assert.rejects(redirects.recordMove('pg_1', 'no-slash', '/x'), /start with/);
++ assert.strictEqual(await redirects.release('/p/rye'), true);
++ assert.strictEqual(await redirects.release('/p/rye'), false);
++ assert.strictEqual(await redirects.resolve('/p/rye', { currentPath }), null);
});
// ---- meta tags ---------------------------------------------------------------------------------
ddiff --git a/test/taxonomy.test.js b/test/taxonomy.test.js
iindex f30dda5..a969414 100644
---- a/test/taxonomy.test.js
++++ b/test/taxonomy.test.js
@@@ -1,10 +1,12 @@
'use strict';
const assert = require('assert');
--const { openDb, suite } = require('./helpers/db');
++const { openDb, countQueries, suite } = require('./helpers/db');
const { createTaxonomy, slugify } = require('../lib/taxonomy');
const { test, run } = suite();
++const taxonomy = async (prefix) => createTaxonomy(await openDb(), { prefix }).ensureSchema();
++
test('slugify', () => {
assert.strictEqual(slugify('Crème Brûlée & Co.'), 'creme-brulee-co');
assert.strictEqual(slugify("Baker's Guide"), 'bakers-guide');
@@@ -13,59 +15,92 @@ test('slugify', () => {
assert.ok(slugify('x'.repeat(200)).length <= 80);
});
--test('terms are unique per vocabulary slug; ensureTerm is get-or-create', () => {
-- const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
-- const a = tax.ensureTerm({ vocabulary: 'tag', name: 'Sourdough' });
-- const b = tax.ensureTerm({ vocabulary: 'tag', name: 'sourdough' });
-- const c = tax.ensureTerm({ vocabulary: 'category', name: 'Sourdough' });
++test('terms are unique per vocabulary slug; ensureTerm is get-or-create', async () => {
++ const tax = await taxonomy('blog');
++ const a = await tax.ensureTerm({ vocabulary: 'tag', name: 'Sourdough' });
++ const b = await tax.ensureTerm({ vocabulary: 'tag', name: 'sourdough' });
++ const c = await tax.ensureTerm({ vocabulary: 'category', name: 'Sourdough' });
assert.strictEqual(a.id, b.id);
assert.notStrictEqual(a.id, c.id);
-- assert.strictEqual(tax.bySlug('tag', 'sourdough').name, 'Sourdough');
++ assert.strictEqual(typeof a.id, 'number');
++ assert.strictEqual((await tax.bySlug('tag', 'sourdough')).name, 'Sourdough');
assert.deepStrictEqual(tax.tables, { terms: 'blog_terms', links: 'blog_term_links' });
++ // concurrent get-or-create of one new slug gives one term
++ const racing = await Promise.all([1, 2, 3].map(() => tax.ensureTerm({ vocabulary: 'tag', name: 'Rye' })));
++ assert.strictEqual(new Set(racing.map((t) => t.id)).size, 1);
});
--test('hierarchical categories: ancestors (breadcrumbs), descendants, tree, cycles refused', () => {
-- const tax = createTaxonomy(openDb('tax'), { prefix: 'wiki' });
-- const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
-- const baking = tax.ensureTerm({ vocabulary: 'category', name: 'Baking', parentId: food.id });
-- const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: baking.id });
-- assert.deepStrictEqual(tax.ancestors(bread.id).map((t) => t.name), ['Food', 'Baking', 'Bread']);
-- assert.deepStrictEqual(tax.descendants(food.id).map((t) => t.name).sort(), ['Baking', 'Bread']);
-- const tree = tax.tree('category');
++test('hierarchical categories: ancestors (breadcrumbs), descendants, tree, cycles refused', async () => {
++ const tax = await taxonomy('wiki');
++ const food = await tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
++ const baking = await tax.ensureTerm({ vocabulary: 'category', name: 'Baking', parentId: food.id });
++ const bread = await tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: baking.id });
++ assert.deepStrictEqual((await tax.ancestors(bread.id)).map((t) => t.name), ['Food', 'Baking', 'Bread']);
++ assert.deepStrictEqual((await tax.descendants(food.id)).map((t) => t.name).sort(), ['Baking', 'Bread']);
++ assert.deepStrictEqual((await tax.children(food.id)).map((t) => t.name), ['Baking']);
++ const tree = await tax.tree('category');
assert.strictEqual(tree.length, 1);
assert.strictEqual(tree[0].children[0].children[0].name, 'Bread');
-- assert.throws(() => tax.setParent(food.id, bread.id), (e) => e.code === 'term.cycle');
-- assert.throws(() => tax.setParent(food.id, food.id), (e) => e.code === 'term.cycle');
-- const tag = tax.ensureTerm({ vocabulary: 'tag', name: 'x' });
-- assert.throws(() => tax.setParent(tag.id, food.id), (e) => e.code === 'term.parent_vocabulary');
++ await assert.rejects(tax.setParent(food.id, bread.id), (e) => e.code === 'term.cycle');
++ await assert.rejects(tax.setParent(food.id, food.id), (e) => e.code === 'term.cycle');
++ const tag = await tax.ensureTerm({ vocabulary: 'tag', name: 'x' });
++ await assert.rejects(tax.setParent(tag.id, food.id), (e) => e.code === 'term.parent_vocabulary');
++ await assert.rejects(tax.setParent(999999, food.id), (e) => e.code === 'term.not_found');
++ assert.strictEqual((await tax.setParent(bread.id, food.id)).parentId, food.id);
++ assert.strictEqual((await tax.setParent(bread.id, null)).parentId, null);
++});
++
++test('setTerms replaces one vocabulary; entitiesFor includes descendants on request', async () => {
++ const tax = await taxonomy('blog');
++ const food = await tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
++ const bread = await tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: food.id });
++ await tax.setTerms('post_1', 'tag', ['Rye', 'rye', 'Starter']);
++ await tax.setTerms('post_1', 'category', [bread.id]);
++ await tax.setTerms('post_2', 'category', [food.id]);
++ assert.deepStrictEqual((await tax.termsFor('post_1', 'tag')).map((t) => t.slug), ['rye', 'starter']);
++ await tax.setTerms('post_1', 'tag', ['Starter']);
++ assert.deepStrictEqual((await tax.termsFor('post_1', 'tag')).map((t) => t.slug), ['starter']);
++ assert.strictEqual((await tax.termsFor('post_1', 'category')).length, 1, 'other vocabularies untouched');
++ assert.deepStrictEqual(await tax.entitiesFor(food.id), ['post_2']);
++ assert.deepStrictEqual(await tax.entitiesFor(food.id, { includeDescendants: true }), ['post_1', 'post_2']);
++ assert.deepStrictEqual(await tax.entitiesFor(food.id, { includeDescendants: true, after: 'post_1' }), ['post_2'], 'keyset page');
++ await assert.rejects(tax.setTerms('post_1', 'tag', [bread.id]), (e) => e.code === 'term.vocabulary_mismatch');
++ await assert.rejects(tax.setTerms('post_1', 'tag', [424242]), (e) => e.code === 'term.not_found');
++ assert.deepStrictEqual((await tax.termsFor('post_1', 'tag')).map((t) => t.slug), ['starter'], 'a refused setTerms changes nothing');
++});
++
++test('setTerms runs a fixed number of statements, however many terms (no N+1)', async () => {
++ const db = await openDb();
++ const t2 = await createTaxonomy(db, { prefix: 'blog' }).ensureSchema();
++ const three = await countQueries(db, () => t2.setTerms('post_a', 'tag', ['a', 'b', 'c']));
++ const forty = await countQueries(db, () => t2.setTerms('post_b', 'tag', Array.from({ length: 40 }, (_, i) => `tag ${i}`)));
++ assert.strictEqual(forty.out.length, 40);
++ assert.strictEqual(forty.count, three.count, `${three.count} statements for 3 terms, ${forty.count} for 40`);
++ const listed = await countQueries(db, () => t2.entitiesFor(forty.out[0].id, { includeDescendants: true }));
++ assert.strictEqual(listed.count, 1);
});
--test('setTerms replaces one vocabulary; entitiesFor includes descendants on request', () => {
-- const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
-- const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
-- const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: food.id });
-- tax.setTerms('post_1', 'tag', ['Rye', 'rye', 'Starter']);
-- tax.setTerms('post_1', 'category', [bread.id]);
-- tax.setTerms('post_2', 'category', [food.id]);
-- assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['rye', 'starter']);
-- tax.setTerms('post_1', 'tag', ['Starter']);
-- assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['starter']);
-- assert.strictEqual(tax.termsFor('post_1', 'category').length, 1, 'other vocabularies untouched');
-- assert.deepStrictEqual(tax.entitiesFor(food.id), ['post_2']);
-- assert.deepStrictEqual(tax.entitiesFor(food.id, { includeDescendants: true }), ['post_1', 'post_2']);
-- assert.throws(() => tax.setTerms('post_1', 'tag', [bread.id]), (e) => e.code === 'term.vocabulary_mismatch');
++test('remove re-parents children and drops links', async () => {
++ const tax = await taxonomy('blog');
++ const a = await tax.ensureTerm({ vocabulary: 'category', name: 'A' });
++ const b = await tax.ensureTerm({ vocabulary: 'category', name: 'B', parentId: a.id });
++ const c = await tax.ensureTerm({ vocabulary: 'category', name: 'C', parentId: b.id });
++ await tax.setTerms('e', 'category', [b.id]);
++ assert.strictEqual(await tax.remove(b.id), true);
++ assert.strictEqual((await tax.get(c.id)).parentId, a.id);
++ assert.deepStrictEqual(await tax.termsFor('e'), []);
++ assert.strictEqual((await tax.rename(a.id, ' All ')).name, 'All');
++ await assert.rejects(tax.rename(a.id, ' '), /name is required/);
++ await assert.rejects(tax.remove(b.id), (e) => e.code === 'term.not_found');
});
--test('remove re-parents children and drops links', () => {
-- const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
-- const a = tax.ensureTerm({ vocabulary: 'category', name: 'A' });
-- const b = tax.ensureTerm({ vocabulary: 'category', name: 'B', parentId: a.id });
-- const c = tax.ensureTerm({ vocabulary: 'category', name: 'C', parentId: b.id });
-- tax.setTerms('e', 'category', [b.id]);
-- tax.remove(b.id);
-- assert.strictEqual(tax.get(c.id).parentId, a.id);
-- assert.deepStrictEqual(tax.termsFor('e'), []);
-- assert.strictEqual(tax.rename(a.id, ' All ').name, 'All');
++test('terms() pages by name with a keyset cursor', async () => {
++ const tax = await taxonomy('blog');
++ for (const n of ['delta', 'alpha', 'echo', 'bravo', 'charlie']) await tax.ensureTerm({ vocabulary: 'tag', name: n });
++ const p1 = await tax.terms('tag', { limit: 2 });
++ const p2 = await tax.terms('tag', { limit: 2, after: p1[1] });
++ const p3 = await tax.terms('tag', { limit: 2, after: p2[1] });
++ assert.deepStrictEqual([...p1, ...p2, ...p3].map((t) => t.name), ['alpha', 'bravo', 'charlie', 'delta', 'echo']);
});
run();
ddiff --git a/test/transactions.test.js b/test/transactions.test.js
nnew file mode 100644
iindex 0000000..f573788
---- /dev/null
++++ b/test/transactions.test.js
@@@ -0,0 +1,110 @@
++'use strict';
++/**
++ * Transaction handles (1.0.0): every store method takes the caller's transaction handle as an optional
++ * first argument, so a product's own write and the stores' writes commit together or not at all; a
++ * store write that fails inside the caller's transaction rolls back only its own savepoint.
++ */
++const assert = require('assert');
++const { openDb, fakeClock, suite, sql } = require('./helpers/db');
++const { createRevisionStore } = require('../lib/revisions');
++const { createCitationStore } = require('../lib/citations');
++const { createTaxonomy } = require('../lib/taxonomy');
++const { createScheduler } = require('../lib/schedule');
++const { createAttachmentStore } = require('../lib/media');
++const { createReviewLog } = require('../lib/authorship');
++const { createRedirectStore } = require('../lib/seo');
++const { createDiscussionRefs } = require('../lib/discussion');
++
++const { test, run } = suite();
++const MED = 'med_01J8Z6Q3KX0000000000000000';
++const USER = 'usr_01J8Z6Q3KX0000000000000000';
++
++async function setup() {
++ const db = await openDb();
++ const now = fakeClock();
++ await db.query('CREATE TABLE pages (id text PRIMARY KEY, title text NOT NULL)');
++ const revisions = await createRevisionStore(db, { prefix: 'wiki_page', now }).ensureSchema();
++ const s = {
++ db,
++ revisions,
++ citations: await createCitationStore(db, { prefix: 'wiki', now, revisions }).ensureSchema(),
++ taxonomy: await createTaxonomy(db, { prefix: 'wiki', now }).ensureSchema(),
++ scheduler: await createScheduler(db, { prefix: 'wiki', now }).ensureSchema(),
++ media: await createAttachmentStore(db, { prefix: 'wiki_page', now }).ensureSchema(),
++ reviews: await createReviewLog(db, { prefix: 'wiki_page', now }).ensureSchema(),
++ redirects: await createRedirectStore(db, { prefix: 'wiki_page', now }).ensureSchema(),
++ refs: await createDiscussionRefs(db, { prefix: 'wiki', now }).ensureSchema(),
++ };
++ return s;
++}
++
++/** Every store write the caller's transaction can carry, plus the product's own row. */
++async function writeAll(t, s, id) {
++ await t.exec(sql`INSERT INTO pages (id, title) VALUES (${id}, ${'Rye'})`);
++ const { revision } = await s.revisions.create(t, { entityId: id, expectedRevision: 0, content: 'Rye is a grass.', fields: { title: 'Rye' } });
++ assert.strictEqual((await s.revisions.head(t, id)).number, 1, 'a read on t sees the transaction\'s own write');
++ await s.citations.attachMany(t, id, revision.number, [{ url: 'https://example.org/rye' }]);
++ await s.taxonomy.setTerms(t, id, 'tag', ['grain']);
++ await s.scheduler.schedule(t, { entityId: id, action: 'publish', runAt: Date.parse('2026-10-01T00:00:00Z'), revision: 1 });
++ await s.media.attach(t, { entityId: id, mediaId: MED });
++ await s.reviews.record(t, { entityId: id, revision: 1, reviewer: USER, decision: 'approved' });
++ await s.redirects.recordMove(t, id, '/p/old', '/p/rye');
++ await s.refs.set(t, id, 'thr_1', { service: 'wiki', type: 'page', id });
++ await s.revisions.saveDraft(t, { entityId: id, owner: USER, content: 'draft' });
++}
++
++async function counts(db) {
++ const n = async (table) => db.value(sql`SELECT count(*) FROM ${sql.ident(table)}`);
++ return {
++ pages: await n('pages'), revisions: await n('wiki_page_revisions'), drafts: await n('wiki_page_drafts'), citations: await n('wiki_citations'),
++ links: await n('wiki_term_links'), jobs: await n('wiki_schedule_jobs'), attachments: await n('wiki_page_attachments'),
++ reviews: await n('wiki_page_reviews'), redirects: await n('wiki_page_redirects'), refs: await n('wiki_discussion_refs'),
++ };
++}
++
++test('a caller\'s transaction spans its own write and every store\'s writes: commit keeps all', async () => {
++ const s = await setup();
++ await s.db.tx((t) => writeAll(t, s, 'pg_1'));
++ assert.deepStrictEqual(await counts(s.db), { pages: 1, revisions: 1, drafts: 1, citations: 1, links: 1, jobs: 1, attachments: 1, reviews: 1, redirects: 1, refs: 1 });
++});
++
++test('a rollback of the caller\'s transaction leaves neither its write nor any store write', async () => {
++ const s = await setup();
++ await assert.rejects(s.db.tx(async (t) => { await writeAll(t, s, 'pg_1'); throw new Error('the product changed its mind'); }), /changed its mind/);
++ assert.deepStrictEqual(await counts(s.db), { pages: 0, revisions: 0, drafts: 0, citations: 0, links: 0, jobs: 0, attachments: 0, reviews: 0, redirects: 0, refs: 0 });
++ assert.strictEqual(await s.taxonomy.bySlug('tag', 'grain'), null, 'a term created on demand rolled back too');
++ // and the same writes succeed afterwards: nothing half-done was left behind
++ await s.db.tx((t) => writeAll(t, s, 'pg_1'));
++ assert.strictEqual((await counts(s.db)).revisions, 1);
++});
++
++test('a store refusal inside the caller\'s transaction undoes only that store call (its savepoint); the caller commits the rest', async () => {
++ const s = await setup();
++ const head = await s.db.tx(async (t) => {
++ await t.exec(sql`INSERT INTO pages (id, title) VALUES ('pg_3', 'Barley')`);
++ await s.revisions.create(t, { entityId: 'pg_3', expectedRevision: 0, content: 'v1' });
++ // A stale write: 412 from the store; the caller catches it and carries on in the same transaction.
++ const err = await s.revisions.create(t, { entityId: 'pg_3', expectedRevision: 0, content: 'lost' }).catch((e) => e);
++ assert.strictEqual(err.status, 412);
++ await s.taxonomy.setTerms(t, 'pg_3', 'tag', ['barley']);
++ // setTerms deletes the old links before it finds the unknown id: the savepoint puts them back.
++ const refused = await s.taxonomy.setTerms(t, 'pg_3', 'tag', ['rye', 987654]).catch((e) => e);
++ assert.strictEqual(refused.code, 'term.not_found');
++ assert.deepStrictEqual((await s.taxonomy.termsFor(t, 'pg_3', 'tag')).map((x) => x.slug), ['barley']);
++ return s.revisions.headNumber(t, 'pg_3');
++ });
++ assert.strictEqual(head, 1);
++ assert.strictEqual((await counts(s.db)).pages, 1, 'the caller\'s write committed');
++ assert.deepStrictEqual((await s.taxonomy.termsFor('pg_3', 'tag')).map((x) => x.slug), ['barley']);
++ assert.strictEqual(await s.taxonomy.bySlug('tag', 'rye'), null, 'the refused call\'s new term rolled back with its savepoint');
++});
++
++test('without a handle each store call is its own transaction; with db as the handle it is the same', async () => {
++ const s = await setup();
++ const a = await s.revisions.create({ entityId: 'e', expectedRevision: 0, content: 'x' });
++ const b = await s.revisions.create(s.db, { entityId: 'e', expectedRevision: 1, content: 'y' });
++ assert.deepStrictEqual([a.revision.number, b.revision.number], [1, 2]);
++ assert.strictEqual(s.db.stats().pool.total, 1);
++});
++
++run();
ddiff --git a/test/two-products.test.js b/test/two-products.test.js
iindex dee1588..4ded727 100644
---- a/test/two-products.test.js
++++ b/test/two-products.test.js
@@@ -4,13 +4,13 @@
* without sharing a database or an authority.
*/
const assert = require('assert');
--const fs = require('fs');
const http = require('http');
const path = require('path');
const contracts = require('openvibe-contracts');
--const { tempDir, fakeClock, suite } = require('./helpers/db');
++const { newDb, resetDb, countQueries, fakeClock, suite, sql } = require('./helpers/db');
const { createWiki } = require('../examples/two-products/wiki/app');
const { createBlog } = require('../examples/two-products/blog/app');
++const migrations = require('../examples/two-products/migrations');
const { test, run } = suite();
const ALEX = 'usr_01J8Z6Q3KX0000000000000000';
@@@ -18,8 +18,14 @@ const EDITOR = 'usr_01J8Z6Q3KX0000000000000001';
const LONG = 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.';
const MED = 'med_01J8Z6Q3KX0000000000000000';
--function tablesOf(db) {
-- return db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name).sort();
++/** Every table, index, trigger and function in the database (the SDK's ov_migrations bookkeeping aside). */
++async function objectsOf(db) {
++ const rows = await db.many(sql`SELECT c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
++ WHERE n.nspname = 'public' AND c.relkind IN ('r', 'i', 'S')
++ UNION ALL SELECT tgname FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace
++ WHERE n.nspname = 'public' AND NOT t.tgisinternal
++ UNION ALL SELECT p.proname FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public'`);
++ return rows.map((r) => r.name).filter((n) => !/^ov_migrations/.test(n)).sort();
}
function listen(handler) {
@@@ -31,17 +37,20 @@ function listen(handler) {
});
}
--function setup() {
++// Two databases, one per product (two PGlite instances: nothing shared, not even the server), emptied per test.
++let dbs = null;
++async function setup() {
++ if (!dbs) dbs = { wiki: await newDb(), blog: await newDb() };
++ await resetDb(dbs.wiki);
++ await resetDb(dbs.blog);
const clock = fakeClock(Date.parse('2026-09-22T12:00:00Z'));
-- const wikiDir = tempDir('wiki');
-- const blogDir = tempDir('blog');
-- const wiki = createWiki({ dbPath: path.join(wikiDir, 'wiki.db'), now: clock });
-- const blog = createBlog({ dbPath: path.join(blogDir, 'blog.db'), now: clock });
-- return { clock, wiki, blog, wikiFile: path.join(wikiDir, 'wiki.db'), blogFile: path.join(blogDir, 'blog.db') };
++ const wiki = await createWiki({ db: dbs.wiki, now: clock });
++ const blog = await createBlog({ db: dbs.blog, now: clock });
++ return { clock, wiki, blog, wikiDb: dbs.wiki, blogDb: dbs.blog };
}
--test('same code: both products run the very same package modules', () => {
-- const { wiki, blog } = setup();
++test('same code: both products run the very same package modules', async () => {
++ const { wiki, blog } = await setup();
const revisionsFile = require.resolve('openvibe-publishing/revisions');
assert.strictEqual(revisionsFile, path.join(__dirname, '..', 'lib', 'revisions.js'));
for (const mod of ['revisions', 'citations', 'seo', 'ssr']) {
@@@ -52,113 +61,145 @@ test('same code: both products run the very same package modules', () => {
wiki.close(); blog.close();
});
--test('no shared database: two files, each holding only its own product\'s tables', () => {
-- const { wiki, blog, wikiFile, blogFile } = setup();
-- assert.notStrictEqual(fs.realpathSync(wikiFile), fs.realpathSync(blogFile));
-- const wt = tablesOf(wiki.db);
-- const bt = tablesOf(blog.db);
++test('the migrations are the stores\' schema(prefix) DDL, as a service writes them', () => {
++ for (const { file, expected, actual } of migrations.check()) {
++ assert.strictEqual(actual, expected, `${path.relative(process.cwd(), file)} is stale: node examples/two-products/migrations.js --write`);
++ }
++});
++
++test('no shared database: two databases, each holding only its own product\'s tables', async () => {
++ const { wiki, blog, wikiDb, blogDb } = await setup();
++ assert.notStrictEqual(wikiDb, blogDb);
++ const wt = await objectsOf(wikiDb);
++ const bt = await objectsOf(blogDb);
assert.ok(wt.length > 5 && bt.length > 5);
assert.ok(wt.every((n) => n.startsWith('wiki_')), `wiki db: ${wt.filter((n) => !n.startsWith('wiki_'))}`);
assert.ok(bt.every((n) => n.startsWith('blog_')), `blog db: ${bt.filter((n) => !n.startsWith('blog_'))}`);
-- for (const t of ['wiki_page_revisions', 'wiki_citations', 'wiki_page_redirects', 'wiki_pages']) assert.ok(wt.includes(t), t);
++ for (const t of ['wiki_page_revisions', 'wiki_citations', 'wiki_page_redirects', 'wiki_pages', 'wiki_page_revisions_guard', 'wiki_page_revisions_no_update']) assert.ok(wt.includes(t), t);
for (const t of ['blog_post_revisions', 'blog_post_citations', 'blog_schedule_jobs', 'blog_terms', 'blog_post_attachments', 'blog_posts']) assert.ok(bt.includes(t), t);
-- const attached = wiki.db.prepare('PRAGMA database_list').all();
-- assert.deepStrictEqual(attached.map((d) => d.name), ['main'], 'nothing attached across products');
++ assert.strictEqual(await wikiDb.value(sql`SELECT count(*) FROM pg_foreign_server`), 0, 'nothing attached across products');
wiki.close(); blog.close();
});
--test('each product owns its state: writes in one never appear in the other', () => {
-- const { wiki, blog } = setup();
-- const { id: pageId } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] });
-- wiki.publish(pageId, { revision: 1, actor: ALEX });
-- const { id: postId } = blog.draft({ title: 'First loaf', body: LONG, authorName: 'Alex', tags: ['rye'] });
-- assert.strictEqual(wiki.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_revisions').get().n, 1);
-- assert.strictEqual(blog.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions').get().n, 1);
-- assert.strictEqual(wiki.revisions.head(postId), null);
-- assert.strictEqual(blog.revisions.head(pageId), null);
-- assert.strictEqual(blog.citations.history(postId).length, 0);
-- assert.strictEqual(wiki.citations.history(pageId).length, 1);
++test('each product owns its state: writes in one never appear in the other', async () => {
++ const { wiki, blog, wikiDb, blogDb } = await setup();
++ const { id: pageId } = await wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] });
++ await wiki.publish(pageId, { revision: 1, actor: ALEX });
++ const { id: postId } = await blog.draft({ title: 'First loaf', body: LONG, authorName: 'Alex', tags: ['rye'] });
++ assert.strictEqual(await wikiDb.value(sql`SELECT count(*) FROM wiki_page_revisions`), 1);
++ assert.strictEqual(await blogDb.value(sql`SELECT count(*) FROM blog_post_revisions`), 1);
++ assert.strictEqual(await wiki.revisions.head(postId), null);
++ assert.strictEqual(await blog.revisions.head(pageId), null);
++ assert.strictEqual((await blog.citations.history(postId)).length, 0);
++ assert.strictEqual((await wiki.citations.history(pageId)).length, 1);
wiki.close(); blog.close();
});
--test('revisions and citations: edit, diff, revert; citations stay on the revision that used them', () => {
-- const { wiki, clock } = setup();
-- const { id } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/a' }, { url: 'https://example.org/b' }] });
-- const [a] = wiki.citations.forRevision(id, 1);
++test('a product write and the stores\' writes share one transaction: a failure leaves none of them', async () => {
++ const { wiki, wikiDb } = await setup();
++ // The second source is invalid: the page row, revision 1 and the first citation all roll back.
++ await assert.rejects(wiki.createPage({ title: 'Oats', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/oats' }, { url: 'ftp://nope' }] }), /http/);
++ assert.strictEqual(await wikiDb.value(sql`SELECT count(*) FROM wiki_pages`), 0);
++ assert.strictEqual(await wikiDb.value(sql`SELECT count(*) FROM wiki_page_revisions`), 0);
++ assert.strictEqual(await wikiDb.value(sql`SELECT count(*) FROM wiki_citations`), 0);
++ const { id } = await wiki.createPage({ title: 'Oats', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/oats' }] });
++ assert.strictEqual((await wiki.revisions.head(id)).number, 1, 'the id is free again');
++ wiki.close();
++});
++
++test('revisions and citations: edit, diff, revert; citations stay on the revision that used them', async () => {
++ const { wiki, clock } = await setup();
++ const { id } = await wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/a' }, { url: 'https://example.org/b' }] });
++ const [a] = await wiki.citations.forRevision(id, 1);
clock.advance(1000);
-- const r2 = wiki.edit(id, { expectedRevision: 1, body: LONG.replace('grass', 'cereal grass'), author: ALEX, keepSources: [a.id] });
-- assert.throws(() => wiki.edit(id, { expectedRevision: 1, body: 'lost update', author: ALEX }), (e) => e.status === 412);
-- const diff = wiki.revisions.diff(id, 1, r2, { mode: 'word' });
++ const r2 = await wiki.edit(id, { expectedRevision: 1, body: LONG.replace('grass', 'cereal grass'), author: ALEX, keepSources: [a.id] });
++ await assert.rejects(wiki.edit(id, { expectedRevision: 1, body: 'lost update', author: ALEX }), (e) => e.status === 412);
++ const diff = await wiki.revisions.diff(id, 1, r2, { mode: 'word' });
assert.ok(diff.content.ops.some((o) => o.op === 'insert' && o.text.includes('cereal')));
-- assert.deepStrictEqual(wiki.citations.forRevision(id, 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
-- assert.deepStrictEqual(wiki.citations.forRevision(id, r2).map((c) => c.url), ['https://example.org/a']);
-- const r3 = wiki.revert(id, { toRevision: 1, expectedRevision: r2, author: ALEX });
-- assert.strictEqual(wiki.revisions.get(id, r3).content, LONG);
-- assert.deepStrictEqual(wiki.citations.forRevision(id, r3).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
-- assert.deepStrictEqual(wiki.revisions.lineage(id).map((r) => r.number), [3, 2, 1]);
++ assert.deepStrictEqual((await wiki.citations.forRevision(id, 1)).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
++ assert.deepStrictEqual((await wiki.citations.forRevision(id, r2)).map((c) => c.url), ['https://example.org/a']);
++ const r3 = await wiki.revert(id, { toRevision: 1, expectedRevision: r2, author: ALEX });
++ assert.strictEqual((await wiki.revisions.get(id, r3)).content, LONG);
++ assert.deepStrictEqual((await wiki.citations.forRevision(id, r3)).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
++ assert.deepStrictEqual((await wiki.revisions.lineage(id)).map((r) => r.number), [3, 2, 1]);
wiki.close();
});
--test('one gate, two editorial policies: the wiki needs sources, the blog does not', () => {
-- const { wiki, blog } = setup();
-- const { id: unsourced } = wiki.createPage({ title: 'Barley', body: LONG, author: ALEX });
-- wiki.publish(unsourced, { revision: 1, actor: ALEX });
-- const { id: post, revision } = blog.draft({ title: 'Barley notes', body: LONG });
-- blog.publishNow(post, revision);
-- const [w] = wiki.published();
-- const [b] = blog.published();
++test('one gate, two editorial policies: the wiki needs sources, the blog does not', async () => {
++ const { wiki, blog } = await setup();
++ const { id: unsourced } = await wiki.createPage({ title: 'Barley', body: LONG, author: ALEX });
++ await wiki.publish(unsourced, { revision: 1, actor: ALEX });
++ const { id: post, revision } = await blog.draft({ title: 'Barley notes', body: LONG });
++ await blog.publishNow(post, revision);
++ const [w] = await wiki.published();
++ const [b] = await blog.published();
assert.deepStrictEqual(w.decision.codes, ['unsourced']);
assert.strictEqual(b.decision.indexable, true);
-- assert.doesNotMatch(wiki.sitemapXml(), /barley/, 'unsourced page stays out of the sitemap');
-- assert.match(wiki.atom(), /Barley/, 'but is listable in the feed (noindex, not hidden)');
-- assert.match(blog.sitemapXml(), /posts\/barley-notes/);
++ assert.doesNotMatch(await wiki.sitemapXml(), /barley/, 'unsourced page stays out of the sitemap');
++ assert.match(await wiki.atom(), /Barley/, 'but is listable in the feed (noindex, not hidden)');
++ assert.match(await blog.sitemapXml(), /posts\/barley-notes/);
wiki.close(); blog.close();
});
--test('AI-generated wiki page: draft + noindex until a person reviews it', () => {
-- const { wiki } = setup();
++test('a feed of many pages costs the same few queries as a feed of one (no N+1)', async () => {
++ const { wiki, wikiDb } = await setup();
++ const one = await wiki.createPage({ title: 'Page 0', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/0' }] });
++ await wiki.publish(one.id, { revision: 1, actor: ALEX });
++ const small = await countQueries(wikiDb, () => wiki.feedItems());
++ for (let i = 1; i < 12; i++) {
++ const p = await wiki.createPage({ title: `Page ${i}`, body: LONG, author: ALEX, sources: [{ url: `https://example.org/${i}` }] });
++ await wiki.publish(p.id, { revision: 1, actor: ALEX });
++ }
++ const large = await countQueries(wikiDb, () => wiki.feedItems());
++ assert.strictEqual(large.out.length, 12);
++ assert.strictEqual(large.count, small.count, `${small.count} queries for 1 page, ${large.count} for 12`);
++ wiki.close();
++});
++
++test('AI-generated wiki page: draft + noindex until a person reviews it', async () => {
++ const { wiki } = await setup();
const authorship = require('openvibe-publishing/authorship');
const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 1, runId: 'run_1' } });
-- const { id, initial } = wiki.createPage({ title: 'Spelt', body: LONG, author: EDITOR, authorship: rec, sources: [{ sourceItemId: 'src_9' }] });
++ const { id, initial } = await wiki.createPage({ title: 'Spelt', body: LONG, author: EDITOR, authorship: rec, sources: [{ sourceItemId: 'src_9' }] });
assert.deepStrictEqual(initial, { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });
-- assert.throws(() => wiki.publish(id, { revision: 1, actor: EDITOR }), /ai_generated_unreviewed/);
-- wiki.reviews.record({ entityId: id, revision: 1, reviewer: EDITOR, decision: 'approved' });
-- wiki.publish(id, { revision: 1, actor: EDITOR });
-- const [p] = wiki.published();
++ await assert.rejects(wiki.publish(id, { revision: 1, actor: EDITOR }), /ai_generated_unreviewed/);
++ await wiki.reviews.record({ entityId: id, revision: 1, reviewer: EDITOR, decision: 'approved' });
++ await wiki.publish(id, { revision: 1, actor: EDITOR });
++ const [p] = await wiki.published();
assert.strictEqual(p.decision.indexable, true);
-- assert.match(wiki.renderPage(p.page), /AI-generated by workflow wiki.generate_page v1, reviewed by a person/);
++ assert.match(await wiki.renderPage(p.page), /AI-generated by workflow wiki.generate_page v1, reviewed by a person/);
wiki.close();
});
test('scheduled blog publication is idempotent across a worker restart', async () => {
-- const { blog, clock, blogFile } = setup();
-- const { id, revision } = blog.draft({ title: 'Scheduled', body: LONG });
++ const { blog, clock, blogDb } = await setup();
++ const { id, revision } = await blog.draft({ title: 'Scheduled', body: LONG });
const at = clock() + 3600e3;
-- blog.schedulePublish(id, { revision, at });
-- blog.schedulePublish(id, { revision, at });
-- assert.strictEqual(blog.scheduler.jobs(id).length, 1);
++ await blog.schedulePublish(id, { revision, at });
++ await blog.schedulePublish(id, { revision, at });
++ assert.strictEqual((await blog.scheduler.jobs(id)).length, 1);
clock.set(at);
-- const [job] = blog.scheduler.claim({ worker: 'w1' }); // worker 1 dies before finishing
++ const [job] = await blog.scheduler.claim({ worker: 'w1' }); // worker 1 dies before finishing
assert.ok(job);
blog.close();
-- const blog2 = createBlog({ dbPath: blogFile, now: clock }); // "restart"
++ const blog2 = await createBlog({ db: blogDb, now: clock }); // "restart": a new process on the same database
clock.advance(60001);
const out = await blog2.runScheduled('w2');
assert.strictEqual(out.done.length, 1);
-- assert.strictEqual(blog2.published().length, 1);
++ assert.strictEqual((await blog2.published()).length, 1);
await blog2.runScheduled('w3');
-- assert.strictEqual(blog2.scheduler.jobs(id)[0].attempts, 2);
++ assert.strictEqual((await blog2.scheduler.jobs(id))[0].attempts, 2);
blog2.close();
});
test('HTTP: pages useful without JavaScript, 301 for old slugs, private/deleted leave feeds and sitemaps', async () => {
-- const { wiki, blog, clock } = setup();
-- const { id } = wiki.createPage({ title: 'Rye', body: `${LONG}\n\nSee [wheat](/p/wheat).`, author: ALEX, sources: [{ url: 'https://example.org/rye', title: 'Rye facts', retrievedAt: '2026-09-20T10:00:00Z', licenseNote: 'CC BY 4.0' }] });
-- wiki.publish(id, { revision: 1, actor: ALEX });
-- wiki.rename(id, 'Rye (grain)');
-- const { id: post, revision } = blog.draft({ title: 'Crumb shots', body: LONG, authorName: 'Alex', tags: ['bread'] });
-- blog.media.attach({ entityId: post, mediaId: MED, role: 'inline', alt: 'crumb' });
-- blog.publishNow(post, revision);
++ const { wiki, blog, clock } = await setup();
++ const { id } = await wiki.createPage({ title: 'Rye', body: `${LONG}\n\nSee [wheat](/p/wheat).`, author: ALEX, sources: [{ url: 'https://example.org/rye', title: 'Rye facts', retrievedAt: '2026-09-20T10:00:00Z', licenseNote: 'CC BY 4.0' }] });
++ await wiki.publish(id, { revision: 1, actor: ALEX });
++ await wiki.rename(id, 'Rye (grain)');
++ const { id: post, revision } = await blog.draft({ title: 'Crumb shots', body: LONG, authorName: 'Alex', tags: ['bread'] });
++ await blog.media.attach({ entityId: post, mediaId: MED, role: 'inline', alt: 'crumb' });
++ await blog.publishNow(post, revision);
const w = await listen(wiki.handler);
const b = await listen(blog.handler);
try {
@@@ -181,18 +222,18 @@ test('HTTP: pages useful without JavaScript, 301 for old slugs, private/deleted
html = await res.text();
assert.strictEqual(res.status, 200);
assert.match(html, /<img src="https:\/\/openvibe.media\/o\/med_/);
-- blog.media.markBroken(MED, 'deleted');
++ await blog.media.markBroken(MED, 'deleted');
html = await (await b.get('/posts/crumb-shots')).text();
assert.match(html, /data-state="broken"/);
assert.doesNotMatch(html, /<img/);
// private: gone from page, feeds and sitemap; the event is internal
-- wiki.setVisibility(id, 'private', { actor: ALEX });
++ await wiki.setVisibility(id, 'private', { actor: ALEX });
assert.strictEqual((await w.get('/p/rye-grain')).status, 404);
assert.doesNotMatch(await (await w.get('/sitemap.xml')).text(), /rye/);
assert.doesNotMatch(await (await w.get('/feed.atom')).text(), /<entry>/);
assert.strictEqual(JSON.parse(await (await w.get('/feed.json')).text()).items.length, 0);
-- let events = wiki.outbox();
++ let events = await wiki.outbox();
assert.deepStrictEqual(events.map((e) => e.event_type), [
'wiki.index_document.upserted', 'wiki.page.published', // publish
'wiki.index_document.upserted', // rename: new canonical URL, new index revision
@@@ -209,17 +250,17 @@ test('HTTP: pages useful without JavaScript, 301 for old slugs, private/deleted
}
for (const d of docs) assert.ok(contracts.validate('search.index-document@1', d).valid);
-- blog.setVisibility(post, 'gated');
++ await blog.setVisibility(post, 'gated');
assert.strictEqual((await b.get('/posts/crumb-shots')).status, 404);
assert.doesNotMatch(await (await b.get('/feed.xml')).text(), /Crumb/);
assert.doesNotMatch(await (await b.get('/sitemap.xml')).text(), /crumb/);
// deleted: 410 on the page and on its old slug; tombstone event
clock.advance(1000);
-- wiki.remove(id, { actor: ALEX });
++ await wiki.remove(id, { actor: ALEX });
assert.strictEqual((await w.get('/p/rye-grain')).status, 410);
assert.strictEqual((await w.get('/p/rye')).status, 410);
-- events = wiki.outbox().slice(-2);
++ events = (await wiki.outbox()).slice(-2);
assert.deepStrictEqual(events.map((e) => e.event_type), ['wiki.index_document.deleted', 'wiki.page.deleted']);
assert.deepStrictEqual(events[0].payload, { type: 'page', id, revision: 4 });
assert.deepStrictEqual(events[0].subject, { type: 'page', id, revision: 4 });
@@@ -228,18 +269,19 @@ test('HTTP: pages useful without JavaScript, 301 for old slugs, private/deleted
} finally { w.close(); b.close(); wiki.close(); blog.close(); }
});
--test('feeds from both products carry only provided dates and authors', () => {
-- const { wiki, blog } = setup();
-- const { id } = wiki.createPage({ title: 'Oats', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/oats' }] });
-- wiki.publish(id, { revision: 1, actor: ALEX });
-- const { id: post, revision } = blog.draft({ title: 'No byline', body: LONG });
-- blog.publishNow(post, revision);
-- const rss = blog.rss();
++test('feeds from both products carry only provided dates and authors', async () => {
++ const { wiki, blog } = await setup();
++ const { id } = await wiki.createPage({ title: 'Oats', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/oats' }] });
++ await wiki.publish(id, { revision: 1, actor: ALEX });
++ const { id: post, revision } = await blog.draft({ title: 'No byline', body: LONG, tags: ['oats', 'breakfast'] });
++ await blog.publishNow(post, revision);
++ const rss = await blog.rss();
assert.doesNotMatch(rss, /dc:creator/, 'no author invented for a post without a byline');
assert.match(rss, /<pubDate>Tue, 22 Sep 2026 12:00:00 GMT<\/pubDate>/);
-- const jf = blog.jsonFeedDoc();
++ const jf = await blog.jsonFeedDoc();
assert.ok(!('authors' in jf.items[0]));
-- const atom = wiki.atom();
++ assert.deepStrictEqual(jf.items[0].tags, ['oats', 'breakfast'], 'tags from termsForMany, in the order given');
++ const atom = await wiki.atom();
assert.doesNotMatch(atom, /<author>/);
assert.match(atom, /<published>2026-09-22T12:00:00.000Z<\/published>/);
wiki.close(); blog.close();