openvibers/openvibe.wiki Commits

Wiki moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.

OP OpenVibers committed 28 Sept 2026 · ec90476740 · 49 files, +1969 −1221

Schema: migrations/0001_initial.sql. It holds the ten authority tables as they were, with epoch milliseconds as bigint and 0/1 flags kept integer so the API is unchanged; identifier columns are COLLATE "C" so they sort as SQLite did. The SQLite RAISE(ABORT) triggers become PL/pgSQL guards with the same messages. Then come openvibe-publishing 1.0's stores and the openvibe-sdk PostgreSQL outbox. Indexes cover children, recent changes and proposals by page.

Code: openvibe-sdk 0.19 db.prepare statements and ambient transactions keep the service's shape. Every data method is async, and so is every route; Express 4 errors reach the handlers.
- Access checks stay synchronous: an actor's roles are read once per entry point (access.loadRoles) and every request resolves them.
- The after-commit queue (Community thread visibility) is per request (AsyncLocalStorage), so concurrent requests never share it.
- Links and page-type infobox values are resolved before the synchronous renderer runs.
- Feeds, sitemaps, history and search use Publishing's batch reads (getMany, forRevisions, latestMany): four queries whatever the page count.
- SQL fixes: ILIKE (SQLite LIKE was case-insensitive), IS NOT DISTINCT FROM, lower() for NOCASE, EXISTS for DISTINCT with ORDER BY, and ON CONFLICT.
- Per-actor limits count on Valkey when VALKEY_URL is set; readiness reports the store (postgresql) and Valkey.

Import: scripts/migrate-to-postgres.js (openvibe-sdk runSqliteMigration). A database written by the current release imported and verified (22 tables, checksums), and the new release then served every page from it: home, space, article, history, sitemap, feed, API and ready.

Tests: every file on PGlite, and npm run test:pg through PgBouncer with openvibe-sdk/testing createTestDb (32/32 each). CI runs both on the containers. Pins: openvibe-sdk v0.19.0, openvibe-publishing v1.0.0, openvibe-contracts v0.76.0. The unit orders after PostgreSQL, PgBouncer and Valkey. README, STATUS.json and .env.example are updated.

Changes

ddiff --git a/.env.example b/.env.example
iindex fc57002..cfa8f86 100644
---- a/.env.example
++++ b/.env.example
@@@ -18,6 +18,14 @@ NODE_ENV=development
#WIKI_LIMITS_HOUR=3000
# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh wiki: the runtime role through PgBouncer,
++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++VALKEY_URL=
++VALKEY_PREFIX=
++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
WIKI_DB_PATH=./data/wiki.db
# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 10fe25d..6c5c162 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
contracts-service: wiki
++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/README.md b/README.md
iindex f6981d7..ed502ca 100644
---- a/README.md
++++ b/README.md
@@@ -26,13 +26,13 @@ its publication state; the packages supply the mechanics.
## Owns
--The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PATH`):
++The ten authority tables of §15.13, in Wiki's own PostgreSQL database (`ov_wiki` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
| Table | What | Mechanics |
|---|---|---|
| `wiki_spaces` | spaces: official / user, public / members / private | Wiki |
| `wiki_pages` | canonical page identity, slug, tree (`parent_id`), publication state, published revision | Wiki |
--| `wiki_page_revisions` | immutable revisions (UPDATE/DELETE abort in SQLite triggers) | `openvibe-publishing/revisions`, prefix `wiki_page` |
++| `wiki_page_revisions` | immutable revisions (UPDATE/DELETE refused by PL/pgSQL triggers) | `openvibe-publishing/revisions`, prefix `wiki_page` |
| `wiki_page_links` | `[[links]]` of every revision (backlinks, red links) | Wiki |
| `wiki_page_redirects` | every historical path of a page or space (301; 410 when gone) | `openvibe-publishing/seo`, prefix `wiki_page` |
| `wiki_citations` | sources per (page, revision), append-only | `openvibe-publishing/citations`, prefix `wiki` |
@@@ -260,18 +260,23 @@ fnm exec --using=22.22.1 npm install
cp .env.example .env
fnm exec --using=22.22.1 npm run seed # the official "OpenVibe" space
fnm exec --using=22.22.1 npm run dev # http://localhost:4800
--fnm exec --using=22.22.1 npm test # every test/*.test.js on temp databases, no network
++fnm exec --using=22.22.1 npm test # every test/*.test.js on PGlite (PostgreSQL in-process), no network
++eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm run test:pg # the same through PgBouncer
```
++Without `DATABASE_URL`, development uses an embedded PGlite database in `data/pglite`.
++
Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
--[deploy/systemd/openvibe-wiki.service](deploy/systemd/openvibe-wiki.service), database
--`/var/lib/openvibe-wiki/wiki.db`, nginx [deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
++[deploy/systemd/openvibe-wiki.service](deploy/systemd/openvibe-wiki.service), database `ov_wiki`
++(`DATABASE_URL` through PgBouncer; migrations on `DATABASE_DIRECT_URL`), nginx [deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
## Depends on
--- `openvibe-publishing` v0.4.0, `openvibe-contracts` v0.64.0, `openvibe-shared` v1.25.0 (chrome,
-- release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.12.0 (auth, events
-- outbox, per-actor limits) — pinned release tarballs.
++- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
++ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
++- `openvibe-publishing` v1.0.0 (async PostgreSQL stores), `openvibe-contracts` v0.76.0, `openvibe-shared` v1.25.0
++ (Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.19.0 (db, auth, PostgreSQL
++ events outbox, per-actor limits, testing) — pinned release tarballs.
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer of
the index events). All but the Network key are optional at runtime and degrade to explicit failure
@@@ -341,7 +346,10 @@ references and the gaps that remain: [docs/threat-review.md](docs/threat-review.
Production deploys with `sudo ovhost deploy wiki` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.wiki`, install on a lockfile change, restart, wait for `/api/ready`).
The unit is `openvibe-wiki.service` on `127.0.0.1:4800`, the env file `/etc/openvibe/wiki.env`. The database is
--`/var/lib/openvibe-wiki/wiki.db`; nginx serves `openvibe.wiki` from
++`ov_wiki` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh wiki` writes its settings);
++the release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
++`runSqliteMigration`: import, verification, and a rehearsal mode `--pglite`), run while the service is stopped;
++the old `/var/lib/openvibe-wiki/wiki.db` stays read-only for 7 days as the rollback. nginx serves `openvibe.wiki` from
[deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
restart; afterwards `sudo ovhost rollback wiki --to <sha>`. Nothing blocks a rollback: the schema
ddiff --git a/STATUS.json b/STATUS.json
iindex 6f187e2..6047a24 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,12 +4,25 @@
"code": true,
"wave": "W16",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "runtime": "node 22, express, better-sqlite3",
++ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4800,
"domain": "openvibe.wiki",
-- "contracts": "openvibe-contracts v0.64.0",
-- "packages": ["openvibe-publishing v0.4.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
-- "capabilities": ["wiki.space.create", "wiki.page.create", "wiki.page.read", "wiki.revision.propose", "wiki.revision.publish", "wiki.revision.revert", "wiki.citation.attach", "wiki.search.query"],
++ "contracts": "openvibe-contracts v0.76.0",
++ "packages": [
++ "openvibe-publishing v1.0.0",
++ "openvibe-shared v1.25.0",
++ "openvibe-sdk v0.19.0"
++ ],
++ "capabilities": [
++ "wiki.space.create",
++ "wiki.page.create",
++ "wiki.page.read",
++ "wiki.revision.propose",
++ "wiki.revision.publish",
++ "wiki.revision.revert",
++ "wiki.citation.attach",
++ "wiki.search.query"
++ ],
"capabilitiesRegistered": true,
"deployed": true,
"deployment": "Public at https://openvibe.wiki since 2026-09-23 (~03:00 UTC): deployed with `sudo ovhost deploy wiki`, /opt/openvibe.wiki, unit openvibe-wiki on 127.0.0.1:4800 behind the openvibe.wiki nginx vhost, principal wiki. On 2026-09-23 production held 1 space and the 10 seed pages (1 revision each), 0 person reviews, so every page was noindex and nothing public was in Search. Restore drill passed 2026-09-23.",
@@@ -30,5 +43,6 @@
"/api/v1 by capability",
"SDK outbox events and Search index documents",
"per-actor limits (openvibe-sdk/limits)"
-- ]
++ ],
++ "database": "PostgreSQL ov_wiki (ADR-035), schema migrations/0001_initial.sql; the SQLite file of earlier releases is imported once by scripts/migrate-to-postgres.js and kept read-only 7 days"
}
ddiff --git a/deploy/systemd/openvibe-wiki.service b/deploy/systemd/openvibe-wiki.service
iindex b800ab5..4495693 100644
---- a/deploy/systemd/openvibe-wiki.service
++++ b/deploy/systemd/openvibe-wiki.service
@@@ -3,8 +3,8 @@ Description=OpenVibe.Wiki — wiki spaces, revisions, citations and discussion (
# Network (openvibe-network) signs the tokens this service verifies. Wiki starts without it and
# keeps retrying the JWKS fetch: public pages work, sign-in and token calls answer 503 meanwhile.
# Events, Community, Sources and Media are optional: each degrades to an explicit failure state.
--After=network.target
--Wants=openvibe-network.service
++After=network.target postgresql.service pgbouncer.service valkey-server.service
++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
Type=simple
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..58b8445
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,347 @@
++-- phase: expand
++-- OpenVibe.Wiki on PostgreSQL (ADR-035, roadmap WS-X2): the ten authority tables of roadmap 15.13 as they were on
++-- SQLite, typed for PostgreSQL (epoch milliseconds stay bigint; 0/1 flags stay integer so the API is unchanged),
++-- then the openvibe-publishing stores and the openvibe-sdk outbox. Identifier columns sort like SQLite (COLLATE "C").
++-- Generated once on 2026-09-28; never edited after it runs (a change is the next migration).
++
++CREATE TABLE wiki_spaces (
++ id text COLLATE "C" PRIMARY KEY,
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ name text NOT NULL,
++ description text,
++ kind text NOT NULL CHECK (kind IN ('official','user')),
++ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
++ owner text COLLATE "C" NOT NULL,
++ created_by text NOT NULL,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ deleted_at bigint
++);
++
++CREATE TABLE wiki_pages (
++ id text COLLATE "C" PRIMARY KEY,
++ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),
++ slug text COLLATE "C" NOT NULL,
++ title text NOT NULL,
++ parent_id text COLLATE "C" REFERENCES wiki_pages(id),
++ position integer NOT NULL DEFAULT 0,
++ state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
++ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
++ noindex integer NOT NULL DEFAULT 0,
++ published_revision integer,
++ published_at bigint,
++ revision_published_at bigint,
++ created_by text NOT NULL,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ UNIQUE (space_id, slug)
++);
++CREATE INDEX wiki_pages_parent ON wiki_pages (space_id, parent_id, position);
++CREATE INDEX wiki_pages_children ON wiki_pages (parent_id) WHERE parent_id IS NOT NULL;
++CREATE INDEX wiki_pages_updated ON wiki_pages (updated_at);
++CREATE INDEX wiki_pages_recent ON wiki_pages (revision_published_at DESC, id) WHERE state = 'published';
++
++CREATE TABLE wiki_page_links (
++ from_page_id text COLLATE "C" NOT NULL,
++ from_revision integer NOT NULL,
++ target_space text COLLATE "C" NOT NULL,
++ target_slug text COLLATE "C" NOT NULL,
++ label text,
++ PRIMARY KEY (from_page_id, from_revision, target_space, target_slug)
++);
++CREATE INDEX wiki_page_links_target ON wiki_page_links (target_space, target_slug);
++
++CREATE TABLE wiki_infobox_values (
++ page_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ position integer NOT NULL,
++ key text COLLATE "C" NOT NULL,
++ label text NOT NULL,
++ type text NOT NULL CHECK (type IN ('text','number','date','url','boolean','page','media')),
++ value_text text,
++ value_number double precision,
++ PRIMARY KEY (page_id, revision, key)
++);
++
++CREATE TABLE wiki_permissions (
++ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),
++ subject text COLLATE "C" NOT NULL,
++ role text COLLATE "C" NOT NULL CHECK (role IN ('owner','editor','viewer')),
++ granted_by text,
++ granted_at bigint NOT NULL,
++ PRIMARY KEY (space_id, subject)
++);
++CREATE INDEX wiki_permissions_subject ON wiki_permissions (subject);
++
++-- People whose wiki.projects user module (Network) needs writing again: marked in the same transaction as the space or
++-- role change, drained by server/integrations/projects-module.js.
++CREATE TABLE wiki_module_dirty (
++ subject text COLLATE "C" PRIMARY KEY,
++ marked_at bigint NOT NULL
++);
++CREATE TABLE wiki_module_pushes (
++ subject text COLLATE "C" PRIMARY KEY,
++ hash text NOT NULL,
++ pushed_at bigint NOT NULL
++);
++
++CREATE TABLE wiki_watchers (
++ page_id text COLLATE "C" NOT NULL REFERENCES wiki_pages(id),
++ subject text COLLATE "C" NOT NULL,
++ created_at bigint NOT NULL,
++ PRIMARY KEY (page_id, subject)
++);
++
++CREATE TABLE wiki_ai_proposals (
++ id text COLLATE "C" PRIMARY KEY,
++ page_id text COLLATE "C" NOT NULL REFERENCES wiki_pages(id),
++ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),
++ revision integer NOT NULL,
++ base_revision integer NOT NULL,
++ workflow_id text NOT NULL,
++ run_id text NOT NULL,
++ stub_provider integer NOT NULL DEFAULT 0,
++ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
++ proposed_by text NOT NULL,
++ note text,
++ reviewed_by text,
++ review_note text,
++ reviewed_at bigint,
++ created_at bigint NOT NULL,
++ UNIQUE (page_id, revision)
++);
++CREATE INDEX wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
++CREATE INDEX wiki_ai_proposals_page ON wiki_ai_proposals (page_id, created_at DESC);
++
++-- Who attached a Media object to a page, and what Media said about it at that moment. One row per attachment,
++-- written with it and never changed.
++CREATE TABLE wiki_attachment_origins (
++ attachment_id bigint PRIMARY KEY,
++ page_id text COLLATE "C" NOT NULL,
++ media_id text COLLATE "C" NOT NULL,
++ attached_by text NOT NULL,
++ media_owner text,
++ media_visibility text NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),
++ attached_at bigint NOT NULL
++);
++CREATE INDEX wiki_attachment_origins_page ON wiki_attachment_origins (page_id);
++
++-- 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/media (prefix wiki_page)
++CREATE TABLE IF NOT EXISTS wiki_page_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 wiki_page_attachments_entity ON wiki_page_attachments (entity_id, position, id);
++CREATE INDEX IF NOT EXISTS wiki_page_attachments_media ON wiki_page_attachments (media_id, entity_id);
++
++-- openvibe-publishing/discussion (prefix wiki)
++CREATE TABLE IF NOT EXISTS wiki_discussion_refs (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ thread_id text NOT NULL,
++ ref jsonb NOT NULL,
++ resolved_at bigint NOT NULL
++);
++
++-- openvibe-publishing/schedule (prefix wiki)
++CREATE TABLE IF NOT EXISTS wiki_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 wiki_schedule_jobs_due ON wiki_schedule_jobs (run_at, id) WHERE status = 'pending';
++CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_lease ON wiki_schedule_jobs (lease_until) WHERE status = 'running';
++CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_entity ON wiki_schedule_jobs (entity_id, run_at, id);
++
++-- 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)
++);
++
++
++-- Immutable side tables (the SQLite RAISE(ABORT) triggers), as PL/pgSQL with the same messages.
++CREATE FUNCTION wiki_rows_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ RAISE EXCEPTION '% rows are immutable', TG_TABLE_NAME USING ERRCODE = 'restrict_violation';
++END
++$$;
++CREATE FUNCTION wiki_infobox_values_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.page_id) THEN
++ RAISE EXCEPTION 'wiki_infobox_values rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE TRIGGER wiki_infobox_values_no_update BEFORE UPDATE ON wiki_infobox_values FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();
++CREATE TRIGGER wiki_infobox_values_no_delete BEFORE DELETE ON wiki_infobox_values FOR EACH ROW EXECUTE FUNCTION wiki_infobox_values_no_delete();
++CREATE TRIGGER wiki_page_links_no_update BEFORE UPDATE ON wiki_page_links FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();
++CREATE TRIGGER wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();
++
++-- openvibe-sdk/events PostgreSQL outbox
++CREATE TABLE IF NOT EXISTS wiki_event_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++CREATE INDEX IF NOT EXISTS wiki_event_outbox_due ON wiki_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS wiki_event_outbox_sent ON wiki_event_outbox (sent_at) WHERE sent_at IS NOT NULL;
ddiff --git a/package-lock.json b/package-lock.json
iindex 46d7ebe..48c0ddc 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,15 +14,33 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.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.19.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
},
"engines": {
"node": ">=22.0.0"
}
},
++ "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==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -224,6 +242,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@@ -306,6 +333,15 @@
"node": ">=4.0.0"
}
},
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@@ -730,6 +766,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@@ -794,12 +873,24 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/lodash.defaults": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
++ "license": "MIT"
++ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@@ -999,9 +1090,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.64.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
-- "integrity": "sha512-WoTx6IpmcWtKM+DMQDYv1qnZOO9wZJbG6wKd5VNjF7H5gOXsFDPIV9UWqf4FHK3dxrg2cVvQxOwTiPxsztqObA==",
++ "version": "0.76.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1015,15 +1106,15 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "0.4.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",
++ "version": "1.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
++ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
-- "better-sqlite3": ">=11",
++ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
},
"peerDependenciesMeta": {
@@@ -1033,12 +1124,32 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.12.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
++ "version": "0.19.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",
++ "integrity": "sha512-5hNT9KVxtkLeGNYirqY1+n939lga/8XPbmrtqISQTNKaXNsS1AHhf4TBwWe+ZAamuCHUADJZu5Ov3OyZLN0nIA==",
"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": {
@@@ -1065,6 +1176,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
++ "node_modules/pg": {
++ "version": "8.23.0",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
++ "license": "MIT",
++ "dependencies": {
++ "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"
++ },
++ "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==",
++ "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==",
++ "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==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "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==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "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==",
++ "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==",
++ "license": "MIT",
++ "dependencies": {
++ "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"
++ },
++ "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==",
++ "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==",
++ "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==",
++ "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==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "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==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@@ -1188,6 +1427,27 @@
"node": ">= 6"
}
},
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@@ -1403,6 +1663,21 @@
"simple-concat": "^1.0.0"
}
},
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@@ -1530,6 +1805,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 712bdb9..5ea6e08 100644
---- a/package.json
++++ b/package.json
@@@ -8,9 +8,15 @@
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"seed": "node scripts/seed.js",
-- "test": "node test/run.js"
++ "test": "node test/run.js",
++ "test:pg": "WIKI_TEST_STORE=pg node test/run.js",
++ "migrate-to-postgres": "node scripts/migrate-to-postgres.js"
},
-- "keywords": ["openvibe", "wiki", "publishing"],
++ "keywords": [
++ "openvibe",
++ "wiki",
++ "publishing"
++ ],
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Wiki",
@@@ -20,12 +26,17 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.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.19.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
++ "pg": "^8.23.0"
},
"engines": {
"node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
}
}
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100644
iindex 0000000..edc4afb
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -0,0 +1,31 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The one-time move of Wiki's SQLite database (WIKI_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure
++ * is openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
++ *
++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
++ *
++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row
++ * counts and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. In
++ * production it runs once, from the new release's directory, as the service user with the service's
++ * environment, while the service is stopped (the write freeze), before the PostgreSQL release starts.
++ *
++ * Every table keeps its name and columns. What the SQLite file holds that PostgreSQL does not take:
++ * the SDK's SQLite outbox bookkeeping becomes the PostgreSQL outbox's (wiki_event_outbox: same rows; lease
++ * columns start empty), and SQLite's own tables (sqlite_sequence) are not copied.
++ */
++require('dotenv').config();
++const { runSqliteMigration } = require('openvibe-sdk/db');
++const { load } = require('../server/config');
++const { MIGRATIONS } = require('../server/db');
++
++const TABLES = {};
++
++if (require.main === module) {
++ const config = load();
++ runSqliteMigration({ service: 'wiki', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
++}
++
++module.exports = { TABLES };
ddiff --git a/scripts/seed.js b/scripts/seed.js
iindex 2e3f859..3952596 100644
---- a/scripts/seed.js
++++ b/scripts/seed.js
@@@ -7,14 +7,14 @@
* npm run seed -- --draft # create as drafts, publish later from the page settings
* npm run seed -- --file other.json
*
-- * Uses WIKI_DB_PATH like the server. Events for the new pages go to the outbox and are delivered
++ * Uses DATABASE_URL (and DATABASE_DIRECT_URL to migrate) like the server. Events for the new pages go to the outbox and are delivered
* by the server's relay (EVENTS_URL) the next time it runs.
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { load } = require('../server/config');
--const { openDb, createStores } = require('../server/db');
++const { openDb, migrate, createStores } = require('../server/db');
const { createPlatform } = require('../server/integrations/platform');
const { createWikiService } = require('../server/wiki/service');
const { seed } = require('../server/wiki/seed');
@@@ -23,10 +23,13 @@ const args = process.argv.slice(2);
const fileArg = args.indexOf('--file');
const file = fileArg >= 0 ? args[fileArg + 1] : path.join(__dirname, '..', 'seeds', 'openvibe.json');
const config = load();
--const db = openDb(config.dbPath);
--const stores = createStores(db);
--const platform = createPlatform({ config, db });
--const svc = createWikiService({ db, stores, outbox: platform.outbox, config });
--const out = seed(svc, JSON.parse(fs.readFileSync(file, 'utf8')), { publish: !args.includes('--draft') });
--console.log(`[seed] space ${out.space.slug}: ${out.created.length} created, ${out.skipped.length} already there; ${platform.outbox.pending()} event(s) waiting in the outbox`);
--db.close();
++(async () => {
++ const db = openDb(config);
++ await migrate(config, { serving: db });
++ const stores = createStores(db);
++ const platform = createPlatform({ config, db });
++ const svc = createWikiService({ db, stores, outbox: platform.outbox, config });
++ const out = await seed(svc, JSON.parse(fs.readFileSync(file, 'utf8')), { publish: !args.includes('--draft') });
++ console.log(`[seed] space ${out.space.slug}: ${out.created.length} created, ${out.skipped.length} already there; ${await platform.outbox.pending()} event(s) waiting in the outbox`);
++ await db.close();
++})().catch((err) => { console.error(`[seed] ${err.message}`); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
iindex d995bec..a401de5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -22,8 +22,10 @@ const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
++function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
const app = express();
++ // Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.
++ const viewers = { ...baseViewers, resolve: async (req, opts) => svc.loadRoles(await baseViewers.resolve(req, opts)) };
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
// One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.
@@@ -33,7 +35,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'wiki', release: release.release });
metrics.registry.gauge({
name: 'wiki_event_outbox', help: 'Events in the outbox by state', labelNames: ['state'],
-- collect: () => [{ labels: { state: 'pending' }, value: platform.outbox.pending() }, { labels: { state: 'rejected' }, value: platform.outbox.rejected() }],
++ collect: () => { const c = platform.outbox.counts(); return [{ labels: { state: 'pending' }, value: c.pending }, { labels: { state: 'rejected' }, value: c.rejected }]; },
});
app.use((req, res, next) => {
@@@ -70,14 +72,21 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
const ready = createReadiness({
service: 'wiki', release: release.release,
checks: [
-- { name: 'db', required: true, check: () => db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'wiki_pages'").get().n === 1 || 'wiki tables missing' },
++ // A real round trip, naming the store that answered (postgresql / pglite), and the schema present.
++ { name: 'db', required: true, check: async () => {
++ const r = await db.ready();
++ if (!r.ok) return r.error;
++ const n = await db.value("SELECT count(*)::int FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'wiki_pages'");
++ return n === 1 ? { ok: true, detail: r.detail } : 'wiki tables missing (migrations did not run)';
++ } },
++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{ name: 'network_jwks', required: false, check: () => { if (keys.loaded()) return true; keys.ensure().catch(() => {}); return 'Network signing key not loaded yet: sign-in and token calls answer 503'; } },
{ name: 'events_relay', required: false, check: () => (platform.eventsConfigured ? true : 'EVENTS_URL or the service principal is not configured: events wait in the outbox') },
{ name: 'community', required: false, check: () => platform.community.configured || 'not configured: discussions show as unavailable' },
{ name: 'sources', required: false, check: () => platform.sources.configured || 'not configured: Sources item citations are refused (URL citations work)' },
{ name: 'media', required: false, check: () => platform.media.configured || 'not configured: attachments are not checked against Media' },
],
-- details: () => ({ outbox: { pending: platform.outbox.pending(), rejected: platform.outbox.rejected() } }),
++ details: async () => ({ outbox: await platform.outbox.refreshCounts() }),
});
app.get('/api/ready', ready.handler);
@@@ -89,7 +98,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
app.use('/api/', limiter(60000, 300));
// Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router
// resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).
-- const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits });
++ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits, valkey });
app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
ddiff --git a/server/config.js b/server/config.js
iindex 4260d51..1e0c811 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -30,6 +30,10 @@ function load(env = process.env) {
minute: Math.max(1, int(env.WIKI_LIMITS_MINUTE, 120)),
hour: Math.max(1, int(env.WIKI_LIMITS_HOUR, 3000)),
},
++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:wiki:' },
++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.WIKI_DB_PATH || './data/wiki.db',
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
ddiff --git a/server/db.js b/server/db.js
iindex 9f68ec5..d154d4c 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -1,6 +1,11 @@
'use strict';
/**
-- * Wiki's own SQLite database (one per service, WAL, created on boot, idempotent).
++ * Wiki's own PostgreSQL database (ADR-035, roadmap WS-X2), through openvibe-sdk/db: async, pooled, one dialect.
++ *
++ * The schema is migrations/NNNN_*.sql (expand/migrate/contract, ADR-028), applied at boot on the owner's direct
++ * connection (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
++ * transaction mode). Ambient transactions (openvibe-sdk ≥ 0.18): inside db.tx(fn), plain db calls — the prepared
++ * statements in server/wiki/service.js and the openvibe-publishing stores — join the transaction.
*
* The ten authority tables of roadmap §15.13:
* wiki_spaces spaces (official editorial spaces and user spaces)
@@@ -13,15 +18,16 @@
* wiki_permissions space roles: owner / editor / viewer
* wiki_watchers who watches a page (notified through an event, never email)
* wiki_ai_proposals AI-authored draft revisions awaiting a person's decision
++ * plus the Publishing helper tables under the same prefixes, wiki_attachment_origins, wiki_module_dirty/_pushes
++ * and wiki_event_outbox (openvibe-sdk/events PostgreSQL outbox).
*
-- * The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,
-- * wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,
-- * wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.
-- * wiki_attachment_origins records who attached each Media object and the read rights checked then.
++ * openDb(config) the serving handle: DATABASE_URL; in development without it, an embedded PGlite
++ * database in data/pglite (one process, nothing to install)
++ * migrate(config, {serving}) apply migrations/ with the owner role (DATABASE_DIRECT_URL), then close it
*/
const fs = require('fs');
const path = require('path');
--const Database = require('better-sqlite3');
++const { createDb } = require('openvibe-sdk/db');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createAttachmentStore } = require('openvibe-publishing/media');
@@@ -31,183 +37,30 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const seo = require('openvibe-publishing/seo');
--const SCHEMA = `
--CREATE TABLE IF NOT EXISTS wiki_spaces (
-- id TEXT PRIMARY KEY,
-- slug TEXT NOT NULL UNIQUE,
-- name TEXT NOT NULL,
-- description TEXT,
-- kind TEXT NOT NULL CHECK (kind IN ('official','user')),
-- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
-- owner TEXT NOT NULL,
-- created_by TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- deleted_at INTEGER
--);
--
--CREATE TABLE IF NOT EXISTS wiki_pages (
-- id TEXT PRIMARY KEY,
-- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
-- slug TEXT NOT NULL,
-- title TEXT NOT NULL,
-- parent_id TEXT REFERENCES wiki_pages(id),
-- position INTEGER NOT NULL DEFAULT 0,
-- state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
-- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
-- noindex INTEGER NOT NULL DEFAULT 0,
-- published_revision INTEGER,
-- published_at INTEGER,
-- revision_published_at INTEGER,
-- created_by TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- UNIQUE (space_id, slug)
--);
--CREATE INDEX IF NOT EXISTS wiki_pages_parent ON wiki_pages (space_id, parent_id, position);
--CREATE INDEX IF NOT EXISTS wiki_pages_updated ON wiki_pages (updated_at);
--
--CREATE TABLE IF NOT EXISTS wiki_page_links (
-- from_page_id TEXT NOT NULL,
-- from_revision INTEGER NOT NULL,
-- target_space TEXT NOT NULL,
-- target_slug TEXT NOT NULL,
-- label TEXT,
-- PRIMARY KEY (from_page_id, from_revision, target_space, target_slug)
--);
--CREATE INDEX IF NOT EXISTS wiki_page_links_target ON wiki_page_links (target_space, target_slug);
--
--CREATE TABLE IF NOT EXISTS wiki_infobox_values (
-- page_id TEXT NOT NULL,
-- revision INTEGER NOT NULL,
-- position INTEGER NOT NULL,
-- key TEXT NOT NULL,
-- label TEXT NOT NULL,
-- type TEXT NOT NULL CHECK (type IN ('text','number','date','url','boolean','page','media')),
-- value_text TEXT,
-- value_number REAL,
-- PRIMARY KEY (page_id, revision, key)
--);
--CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_update BEFORE UPDATE ON wiki_infobox_values
--BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are immutable'); END;
--CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_delete BEFORE DELETE ON wiki_infobox_values
--WHEN NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.page_id)
--BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are never deleted outside a recorded purge'); END;
--CREATE TRIGGER IF NOT EXISTS wiki_page_links_no_update BEFORE UPDATE ON wiki_page_links
--BEGIN SELECT RAISE(ABORT, 'wiki_page_links rows are immutable'); END;
--
--CREATE TABLE IF NOT EXISTS wiki_permissions (
-- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
-- subject TEXT NOT NULL,
-- role TEXT NOT NULL CHECK (role IN ('owner','editor','viewer')),
-- granted_by TEXT,
-- granted_at INTEGER NOT NULL,
-- PRIMARY KEY (space_id, subject)
--);
--CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);
--
---- People whose wiki.projects user module (Network) needs writing again: marked in the same transaction as
---- the space or role change, drained by server/integrations/projects-module.js.
--CREATE TABLE IF NOT EXISTS wiki_module_dirty (
-- subject TEXT PRIMARY KEY,
-- marked_at INTEGER NOT NULL
--);
--CREATE TABLE IF NOT EXISTS wiki_module_pushes (
-- subject TEXT PRIMARY KEY,
-- hash TEXT NOT NULL,
-- pushed_at INTEGER NOT NULL
--);
--
--CREATE TABLE IF NOT EXISTS wiki_watchers (
-- page_id TEXT NOT NULL REFERENCES wiki_pages(id),
-- subject TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- PRIMARY KEY (page_id, subject)
--);
--
--CREATE TABLE IF NOT EXISTS wiki_ai_proposals (
-- id TEXT PRIMARY KEY,
-- page_id TEXT NOT NULL REFERENCES wiki_pages(id),
-- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
-- revision INTEGER NOT NULL,
-- base_revision INTEGER NOT NULL,
-- workflow_id TEXT NOT NULL,
-- run_id TEXT NOT NULL,
-- stub_provider INTEGER NOT NULL DEFAULT 0,
-- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
-- proposed_by TEXT NOT NULL,
-- note TEXT,
-- reviewed_by TEXT,
-- review_note TEXT,
-- reviewed_at INTEGER,
-- created_at INTEGER NOT NULL,
-- UNIQUE (page_id, revision)
--);
--CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
---- Who attached a Media object to a page, and what Media said about it at that moment (the read
---- rights that were checked). One row per attachment, written with it and never changed.
--CREATE TABLE IF NOT EXISTS wiki_attachment_origins (
-- attachment_id INTEGER PRIMARY KEY,
-- page_id TEXT NOT NULL,
-- media_id TEXT NOT NULL,
-- attached_by TEXT NOT NULL,
-- media_owner TEXT,
-- media_visibility TEXT NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),
-- attached_at INTEGER NOT NULL
--);
--CREATE TRIGGER IF NOT EXISTS wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins
--BEGIN SELECT RAISE(ABORT, 'wiki_attachment_origins rows are immutable'); END;
--`;
--
--function openDb(dbPath) {
-- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
-- const db = new Database(dbPath);
-- db.pragma('journal_mode = WAL');
-- db.pragma('foreign_keys = ON');
-- db.pragma('busy_timeout = 5000');
-- return db;
++function openDb(config, { registry, log = console } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh wiki)');
++ log.warn(`[Wiki] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
++ fs.mkdirSync(DEV_PGLITE, { recursive: true });
++ return createDb({ pglite: DEV_PGLITE, service: 'wiki', registry, log });
++ }
++ return createDb({ url: config.db.url, service: 'wiki', registry, log });
}
--/**
-- * Creates every table (package tables first: the infobox purge trigger references
-- * wiki_page_revision_purges) and returns the package stores bound to this database.
-- */
--/**
-- * Contracts 0.64.0 (WS-K task 8) adds the `vip` visibility. Tables created before it carry
-- * CHECK (visibility IN ('public','members','private')); SQLite cannot ALTER a CHECK, and widening one
-- * changes no stored row, so the table's CREATE statement is rewritten in place (SQLite's documented
-- * writable_schema procedure, in one transaction with a schema_version bump), then integrity-checked.
-- * Idempotent: a database already on the new text is left alone. Returns the tables changed.
-- */
--const OLD_VIS_CHECK = "CHECK (visibility IN ('public','members','private'))";
--const NEW_VIS_CHECK = "CHECK (visibility IN ('public','members','vip','private'))";
--function widenVisibilityChecks(db) {
-- const rows = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name IN ('wiki_spaces', 'wiki_pages')").all()
-- .filter((r) => r.sql && r.sql.includes(OLD_VIS_CHECK));
-- if (!rows.length) return [];
-- db.unsafeMode(true);
-- try {
-- db.transaction(() => {
-- const version = db.pragma('schema_version', { simple: true });
-- db.pragma('writable_schema = ON');
-- const set = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");
-- for (const r of rows) set.run(r.sql.split(OLD_VIS_CHECK).join(NEW_VIS_CHECK), r.name);
-- db.pragma(`schema_version = ${Number(version) + 1}`);
-- db.pragma('writable_schema = OFF');
-- })();
-- } finally {
-- db.unsafeMode(false);
-- }
-- const check = db.pragma('integrity_check', { simple: true });
-- if (check !== 'ok') throw new Error(`wiki: integrity_check after widening the visibility CHECK: ${check}`);
-- return rows.map((r) => r.name);
++/** Apply pending migrations (several processes starting together are safe: the SDK takes an advisory lock). */
++async function migrate(config, { serving = null, log = console } = {}) {
++ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'wiki-migrate', max: 1, log });
++ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
}
++/** The openvibe-publishing stores bound to this database (their tables are in migrations/). */
function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } = {}) {
const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
-- db.exec(SCHEMA);
-- widenVisibilityChecks(db);
return {
revisions,
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
@@@ -220,4 +73,4 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
};
}
--module.exports = { openDb, createStores, widenVisibilityChecks, SCHEMA };
++module.exports = { openDb, migrate, createStores, MIGRATIONS };
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 8ba920e..cfadb14 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -24,7 +24,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages people read
* (the per-address limits bound those).
*/
--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
@@@ -93,7 +93,7 @@ const BUDGETS = {
* enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the
* per-address limits.
*/
--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
const refused = registry
? registry.counter({ name: 'wiki_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
@@@ -101,6 +101,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.limits.minute, hour: config.limits.hour },
actor: enabled ? actor : () => null,
now,
++ // Shared across processes and hosts on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
// The actor is a subject id, a principal or an address, never a token.
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 9b552fb..53b2d35 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -104,51 +104,56 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
const B = (name) => limits.budget(name);
const R = (fn, status) => run(fn, status, log);
// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.
-- const prepare = (req, pairs, next) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs).then(() => next(), next) : next());
++ const prepare = (req, pairs) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs) : null);
router.param('space', (req, res, next, ref) => {
-- const space = svc.findSpace(ref);
-- return space && !space.deleted_at ? prepare(req, [{ space }, ...svc.vipPagesOf(space.id).map((page) => ({ space, page }))], next) : next();
++ (async () => {
++ const space = await svc.findSpace(ref);
++ if (space && !space.deleted_at) await prepare(req, [{ space }, ...(await svc.vipPagesOf(space.id)).map((page) => ({ space, page }))]);
++ })().then(() => next(), next);
});
router.param('id', (req, res, next, id) => {
-- const page = svc.pageById(id);
-- const space = page && svc.spaceById(page.space_id);
-- return space ? prepare(req, [{ space }, { space, page }], next) : next();
++ (async () => {
++ const page = await svc.pageById(id);
++ const space = page && await svc.spaceById(page.space_id);
++ if (space) await prepare(req, [{ space }, { space, page }]);
++ })().then(() => next(), next);
});
-- const pageAndSpace = (id) => {
-- const page = svc.pageById(id);
++ const pageAndSpace = async (id) => {
++ const page = await svc.pageById(id);
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
-- const space = svc.spaceById(page.space_id);
++ const space = await svc.spaceById(page.space_id);
return { page, space };
};
-- const readable = (req, id) => {
-- const { page, space } = pageAndSpace(id);
++ const readable = async (req, id) => {
++ const { page, space } = await pageAndSpace(id);
if (!svc.access.canReadPage(space, page, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
return { page, space };
};
++ const pageOut = async (page) => serializePage(page, svc, await svc.spaceById(page.space_id));
// Spaces
-- router.get('/spaces', R((req) => ({ spaces: svc.listSpaces(req.actor).map(serializeSpace) })));
-- router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));
-- router.get('/spaces/:space', R((req) => {
-- const space = svc.getSpace(req.params.space, req.actor);
-- return { space: serializeSpace(space), pages: serializeTree(svc.tree(space, req.actor), svc, space) };
++ router.get('/spaces', R(async (req) => ({ spaces: (await svc.listSpaces(req.actor)).map(serializeSpace) })));
++ router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R(async (req) => ({ space: serializeSpace(await svc.createSpace(req.body || {}, req.actor)) }), 201));
++ router.get('/spaces/:space', R(async (req) => {
++ const space = await svc.getSpace(req.params.space, req.actor);
++ return { space: serializeSpace(space), pages: serializeTree(await svc.tree(space, req.actor), svc, space) };
}));
-- router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));
-- router.get('/spaces/:space/roles', R((req) => ({ roles: svc.roles(req.params.space, req.actor) })));
-- router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));
++ router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R(async (req) => ({ space: serializeSpace(await svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));
++ router.get('/spaces/:space/roles', R(async (req) => ({ roles: await svc.roles(req.params.space, req.actor) })));
++ router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R(async (req) => ({ roles: await svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));
router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
-- const out = svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
-- const space = svc.spaceById(out.page.space_id);
-- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
++ const out = await svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
++ const space = await svc.spaceById(out.page.space_id);
++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
}, 201));
router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {
-- const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);
++ const { space, bundle } = await svc.prepareImport(req.params.space, req.body, req.actor);
await resolveBundleCitations(bundle, platform);
-- const out = svc.importPages(space.id, bundle, req.actor);
++ const out = await svc.importPages(space.id, bundle, req.actor);
return {
space: serializeSpace(out.space), published: out.published, skipped: out.skipped,
created: out.created.map((c) => ({ ...serializePage(c.page, svc, out.space), revision: c.revision.number })),
@@@ -156,9 +161,9 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
}, 201));
// Pages
-- router.get('/pages/:id', guard('wiki.page.read'), R((req) => {
-- const { page, space } = readable(req, req.params.id);
-- const v = svc.view(space, page, req.actor, { revision: int(req.query.revision) });
++ router.get('/pages/:id', guard('wiki.page.read'), R(async (req) => {
++ const { page, space } = await readable(req, req.params.id);
++ const v = await svc.view(space, page, req.actor, { revision: int(req.query.revision) });
return {
page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
citations: v.citations.map(serializeCitation), infobox: v.infobox,
@@@ -168,75 +173,76 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
discussion_thread: v.discussion ? v.discussion.threadId : null,
};
}));
-- router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {
++ router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R(async (req) => {
const b = req.body || {};
-- let page = svc.pageById(req.params.id);
++ let page = await svc.pageById(req.params.id);
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
-- if (b.slug !== undefined || b.parent_id !== undefined) page = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id }, req.actor);
-- if (b.visibility !== undefined || b.noindex !== undefined) page = svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor).page;
-- return { page: serializePage(page, svc, svc.spaceById(page.space_id)) };
++ if (b.slug !== undefined || b.parent_id !== undefined) page = await svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id }, req.actor);
++ if (b.visibility !== undefined || b.noindex !== undefined) page = (await svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor)).page;
++ return { page: await pageOut(page) };
}));
-- router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {
-- const out = svc.deletePage(req.params.id, req.actor);
-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)) };
++ router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R(async (req) => {
++ const out = await svc.deletePage(req.params.id, req.actor);
++ return { page: await pageOut(out.page) };
}));
-- router.get('/pages/:id/revisions', guard('wiki.page.read'), R((req) => {
-- const { page, space } = readable(req, req.params.id);
++ router.get('/pages/:id/revisions', guard('wiki.page.read'), R(async (req) => {
++ const { page, space } = await readable(req, req.params.id);
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
-- return { revisions: svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor }).map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };
++ const list = await svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor });
++ return { revisions: list.map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };
}));
router.post('/pages/:id/revisions', guard('wiki.page.create'), B('wiki.page.edit'), R(async (req) => {
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
-- const out = svc.editPage(req.params.id, {
++ const out = await svc.editPage(req.params.id, {
expectedRevision: int(b.expected_revision), title: b.title, body: b.body, summary: b.summary, infobox: b.infobox,
citations: cites, keepCitations: b.keep_citations === undefined ? 'all' : b.keep_citations, message: b.message,
}, req.actor);
-- const space = svc.spaceById(out.page.space_id);
-- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
++ const space = await svc.spaceById(out.page.space_id);
++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
}, (out) => (out.created ? 201 : 200)));
-- router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R((req) => {
-- const { page, space } = readable(req, req.params.id);
-- const v = svc.view(space, page, req.actor, { revision: int(req.params.n) });
++ router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R(async (req) => {
++ const { page, space } = await readable(req, req.params.id);
++ const v = await svc.view(space, page, req.actor, { revision: int(req.params.n) });
return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
}));
-- router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R((req) => {
-- const { page, space } = readable(req, req.params.id);
++ router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R(async (req) => {
++ const { page, space } = await readable(req, req.params.id);
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
-- return { diff: svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };
++ return { diff: await svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };
}));
-- router.get('/pages/:id/revisions/:n/citations', guard('wiki.page.read'), R((req) => {
-- const { page, space } = readable(req, req.params.id);
-- svc.view(space, page, req.actor, { revision: int(req.params.n) });
-- return { citations: svc.citationsOf(page, int(req.params.n)).map(serializeCitation) };
++ router.get('/pages/:id/revisions/:n/citations', guard('wiki.page.read'), R(async (req) => {
++ const { page, space } = await readable(req, req.params.id);
++ await svc.view(space, page, req.actor, { revision: int(req.params.n) });
++ return { citations: (await svc.citationsOf(page, int(req.params.n))).map(serializeCitation) };
}));
router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), B('wiki.citation.attach'), R(async (req) => {
const cites = await resolveCitations((req.body || {}).citations, platform);
-- return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };
++ return { citations: (await svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor)).map(serializeCitation) };
}, 201));
-- router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
-- const { page, space } = pageAndSpace(req.params.id);
++ router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
++ const { page, space } = await pageAndSpace(req.params.id);
const b = req.body || {};
-- const out = svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
++ const out = await svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };
}, 201));
-- router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
-- const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
++ router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
++ const out = await svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
++ return { page: await pageOut(out.page), action: out.action };
}));
-- router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
++ router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
const b = req.body || {};
-- const out = svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);
++ const out = await svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);
return { job: out.job, created: out.created };
}, (out) => (out.created ? 201 : 200)));
-- router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
-- const out = svc.unpublish(req.params.id, req.actor);
-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
++ router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
++ const out = await svc.unpublish(req.params.id, req.actor);
++ return { page: await pageOut(out.page), action: out.action };
}));
-- router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R((req) => {
++ router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R(async (req) => {
const b = req.body || {};
-- const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
++ const out = await svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
++ return { page: await pageOut(out.page), revision: serializeRevision(out.revision), published: out.published };
}, 201));
router.post('/pages/:id/media', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
const b = req.body || {};
@@@ -244,7 +250,7 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
return { attachment: serializeAttachment(a) };
}, 201));
router.post('/pages/:id/media/verify', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
-- const { space } = pageAndSpace(req.params.id);
++ const { space } = await pageAndSpace(req.params.id);
if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');
return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };
}));
@@@ -255,16 +261,16 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const wf = b.workflow || {};
-- const out = svc.propose({
++ const out = await svc.propose({
space: b.space, pageId: b.page_id || null, title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, citations: cites,
workflow: { id: wf.id, runId: wf.run_id || wf.runId, version: wf.version, model: wf.model }, stubProvider: !!b.stub_provider,
expectedRevision: b.expected_revision, note: b.note,
}, req.actor);
-- return { proposal: out.proposal, page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision) };
++ return { proposal: out.proposal, page: await pageOut(out.page), revision: serializeRevision(out.revision) };
}, 201));
-- router.get('/proposals/:id', guard('wiki.page.read'), R((req) => {
-- const p = svc.getProposal(req.params.id);
-- if (!p || !svc.access.canEdit(svc.spaceById(p.space_id), req.actor)) {
++ router.get('/proposals/:id', guard('wiki.page.read'), R(async (req) => {
++ const p = await svc.getProposal(req.params.id);
++ if (!p || !svc.access.canEdit(await svc.spaceById(p.space_id), req.actor)) {
// Proposers may read their own proposal's state.
if (!p || !req.actor || req.actor.kind !== 'service' || p.proposed_by !== req.actor.service) throw new svc.WikiError(404, 'proposal.not_found', 'No such proposal');
}
@@@ -275,8 +281,8 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);
}));
-- router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R((req) => ({
-- results: svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit }).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),
++ router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R(async (req) => ({
++ results: (await svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit })).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),
})));
router.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex e2070c2..2352a4e 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -28,14 +28,16 @@ function createMachine({ svc, config }) {
}));
});
-- function pageEntries() {
-- return svc.publishedPublic().map(({ space, page, decision }) => ({
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++
++ async function pageEntries() {
++ return (await svc.publishedPublic()).map(({ space, page, decision }) => ({
loc: svc.pageUrl(space, page), lastmod: page.revision_published_at, decision,
}));
}
-- function spaceEntries() {
-- return svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => {
++ async function spaceEntries() {
++ return (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => {
const url = seo.canonicalUrl(origin, svc.spacePath(s));
// A space index is navigation, not an article: no word or source minimum.
const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0, requireSources: false }, now: Date.now() });
@@@ -43,22 +45,22 @@ function createMachine({ svc, config }) {
});
}
-- router.get('/sitemap.xml', (_req, res) => {
-- const pages = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP });
++ router.get('/sitemap.xml', wrap(async (_req, res) => {
++ const pages = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP });
const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));
cache(res).type('application/xml').send(seo.sitemapIndex(maps));
-- });
-- router.get('/sitemaps/spaces.xml', (_req, res) => cache(res).type('application/xml').send(seo.sitemap(spaceEntries()).files[0]));
-- router.get('/sitemaps/pages-:n.xml', (req, res) => {
-- const files = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP }).files;
++ }));
++ router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => cache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));
++ router.get('/sitemaps/pages-:n.xml', wrap(async (req, res) => {
++ const files = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP }).files;
const n = Number(req.params.n);
if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
cache(res).type('application/xml').send(files[n - 1]);
-- });
++ }));
-- function feedItems() {
-- return svc.recentChanges(50).map(({ space, page, rev, decision }) => ({
++ async function feedItems() {
++ return (await svc.recentChanges(50)).map(({ space, page, rev, decision }) => ({
id: `tag:openvibe.wiki,2026:page/${page.id}/revision/${page.published_revision}`,
url: svc.pageUrl(space, page),
title: rev.fields.title,
@@@ -76,22 +78,22 @@ function createMachine({ svc, config }) {
* change readers cannot see — or the Unix epoch when there is no public space at all. The feed is
* linked from every page, so an empty one is a valid feed with zero entries, not a 404.
*/
-- function emptyFeedUpdated() {
-- const times = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => s.updated_at);
++ async function emptyFeedUpdated() {
++ const times = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => s.updated_at);
return new Date(times.length ? Math.max(...times) : 0).toISOString();
}
-- router.get('/feed.atom', (_req, res) => {
-- const items = feedItems();
-- const updated = items.some((i) => i.decision.listable) ? null : emptyFeedUpdated();
++ router.get('/feed.atom', wrap(async (_req, res) => {
++ const items = await feedItems();
++ const updated = items.some((i) => i.decision.listable) ? null : await emptyFeedUpdated();
cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
-- });
-- router.get('/feed.json', (_req, res) => {
-- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, feedItems())));
-- });
++ }));
++ router.get('/feed.json', wrap(async (_req, res) => {
++ cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));
++ }));
-- router.get('/llms.txt', (_req, res) => {
-- const spaces = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public');
++ router.get('/llms.txt', wrap(async (_req, res) => {
++ const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Wiki',
summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',
@@@ -105,7 +107,7 @@ function createMachine({ svc, config }) {
] },
],
}));
-- });
++ }));
return router;
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5b011bd..3993395 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -49,11 +49,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
// VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this
// URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.
router.param('space', (req, res, next, ref) => {
-- const space = svc.findSpace(ref);
-- if (!space || space.deleted_at || !req.actor || !req.actor.subject) return next();
-- const page = req.params.slug ? svc.findPage(space.id, req.params.slug) : null;
-- const pairs = [{ space }, ...(page ? [{ space, page }] : svc.vipPagesOf(space.id).map((p) => ({ space, page: p })))];
-- svc.access.prepareVip(req.actor, pairs, { sensitive: /\.json$/.test(req.path) }).then(() => next(), next);
++ (async () => {
++ const space = await svc.findSpace(ref);
++ if (!space || space.deleted_at || !req.actor || !req.actor.subject) return;
++ const page = req.params.slug ? await svc.findPage(space.id, req.params.slug) : null;
++ const pairs = [{ space }, ...(page ? [{ space, page }] : (await svc.vipPagesOf(space.id)).map((p) => ({ space, page: p })))];
++ await svc.access.prepareVip(req.actor, pairs, { sensitive: /\.json$/.test(req.path) });
++ })().then(() => next(), next);
});
const origin = new URL(config.baseUrl).origin;
@@@ -87,12 +89,12 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
});
/** Resolve :space/:slug; follows history-aware redirects (301) and reports deleted pages (410). */
-- function locate(req, res, suffix = '') {
-- const space = svc.findSpace(req.params.space);
-- const page = space && !space.deleted_at ? svc.findPage(space.id, req.params.slug) : null;
++ async function locate(req, res, suffix = '') {
++ const space = await svc.findSpace(req.params.space);
++ const page = space && !space.deleted_at ? await svc.findPage(space.id, req.params.slug) : null;
if (page && page.state === 'deleted') { gone(req, res); return null; }
if (!page) {
-- const r = svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
++ const r = await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
notFound(req, res);
@@@ -102,10 +104,10 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
return { space, page };
}
-- function locateSpace(req, res) {
-- const space = svc.findSpace(req.params.space);
++ async function locateSpace(req, res) {
++ const space = await svc.findSpace(req.params.space);
if (!space || space.deleted_at) {
-- const r = svc.resolveRedirect(`/s/${req.params.space}`);
++ const r = await svc.resolveRedirect(`/s/${req.params.space}`);
if (r && r.status === 301) { res.redirect(301, r.location + req.path.replace(/^\/s\/[^/]+/, '')); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
notFound(req, res);
@@@ -117,9 +119,8 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
// ── Home, recent, search ─────────────────────────────────
router.get('/', wrap(async (req, res) => {
-- if (req.actor.subject) await svc.access.prepareVip(req.actor, svc.vipSpaces().map((space) => ({ space }))); // VIP spaces the viewer may see
-- const spaces = svc.listSpaces(req.actor);
-- const recent = svc.recentChanges(10);
++ if (req.actor.subject) await svc.access.prepareVip(req.actor, (await svc.vipSpaces()).map((space) => ({ space }))); // VIP spaces the viewer may see
++ const [spaces, recent] = await Promise.all([svc.listSpaces(req.actor), svc.recentChanges(10)]);
send(req, res, 200, views.home({ spaces, recent, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
robots: 'index, follow', cache: 'public', active: 'home', path: '/',
jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Wiki', description: 'Wiki spaces of the OpenVibe network.' }),
@@@ -127,11 +128,11 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}));
// What shipped on OpenVibe.Wiki: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'wiki', siteName: 'OpenVibe.Wiki' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Wiki', robots: 'index, follow', cache: 'public', path: '/updates' }));
-- router.get('/recent', (req, res) => send(req, res, 200, views.recentPage({ items: svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' }));
-- router.get('/search', (req, res) => {
++ router.get('/recent', wrap(async (req, res) => send(req, res, 200, views.recentPage({ items: await svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' })));
++ router.get('/search', wrap(async (req, res) => {
const query = String(req.query.q || '').slice(0, 200);
-- send(req, res, 200, views.searchPage({ query, results: query ? svc.search(query, req.actor) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query, active: 'search' });
-- });
++ send(req, res, 200, views.searchPage({ query, results: query ? await svc.search(query, req.actor) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query, active: 'search' });
++ }));
// ── Spaces ───────────────────────────────────────────────
router.get('/new-space', (req, res) => {
@@@ -142,7 +143,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
const b = req.body || {};
try {
-- const space = svc.createSpace({ name: b.name, slug: b.slug || null, description: b.description || null, visibility: b.visibility, kind: b.official === '1' && req.actor.staff ? 'official' : 'user' }, req.actor);
++ const space = await svc.createSpace({ name: b.name, slug: b.slug || null, description: b.description || null, visibility: b.visibility, kind: b.official === '1' && req.actor.staff ? 'official' : 'user' }, req.actor);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
} catch (err) {
if (!err.status || err.status >= 500) throw err;
@@@ -150,18 +151,18 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}));
-- router.get('/s/:space', (req, res) => {
-- const space = locateSpace(req, res);
++ router.get('/s/:space', wrap(async (req, res) => {
++ const space = await locateSpace(req, res);
if (!space) return;
const canEdit = svc.access.canEdit(space, req.actor);
-- const proposals = canEdit ? svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id) : [];
++ const proposals = canEdit ? (await svc.pendingProposals(req.actor)).filter((p) => p.space_id === space.id) : [];
const open = space.visibility === 'public';
-- send(req, res, 200, views.spacePage({ space, tree: svc.tree(space, req.actor), canEdit, canManage: svc.access.canManage(space, req.actor), proposals }), {
++ send(req, res, 200, views.spacePage({ space, tree: await svc.tree(space, req.actor), canEdit, canManage: svc.access.canManage(space, req.actor), proposals }), {
title: space.name, description: space.description || `${space.name}: a space on OpenVibe.Wiki.`, path: svc.spacePath(space),
robots: open ? 'index, follow' : 'noindex, nofollow', cache: open ? 'public' : null,
jsonLd: open ? seo.structuredData.breadcrumbs([{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }]) : null,
});
-- });
++ }));
function editorsOnly(req, res, space) {
if (svc.access.canEdit(space, req.actor)) return true;
@@@ -170,31 +171,32 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
return false;
}
-- function parentsOf(space, actor) {
++ async function parentsOf(space, actor) {
const out = [];
const walk = (nodes) => { for (const n of nodes) { out.push(n.page); walk(n.children); } };
-- walk(svc.tree(space, actor));
++ walk(await svc.tree(space, actor));
return out;
}
-- router.get('/s/:space/new', (req, res) => {
-- const space = locateSpace(req, res);
++ router.get('/s/:space/new', wrap(async (req, res) => {
++ const space = await locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
-- send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
-- });
++ send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: await parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
++ }));
router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {
-- const space = locateSpace(req, res);
++ const space = await locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
const b = req.body || {};
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };
-- const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
++ const parents = await parentsOf(space, req.actor);
++ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
try {
const infobox = content.parseInfoboxText(b.infobox);
-- if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
++ if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
const cites = await resolveCitations(citationsFromForm(b), platform);
-- const out = svc.createPage(space.id, { title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message || null }, req.actor);
-- if (b.op === 'publish') svc.publish(out.page.id, { revision: out.revision.number }, req.actor);
++ const out = await svc.createPage(space.id, { title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message || null }, req.actor);
++ if (b.op === 'publish') await svc.publish(out.page.id, { revision: out.revision.number }, req.actor);
res.redirect(303, views.wpath(space, out.page));
} catch (err) {
if (!err.status || err.status >= 500 && err.status !== 503) throw err;
@@@ -202,26 +204,26 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}));
-- router.get('/s/:space/settings', (req, res) => {
-- const space = locateSpace(req, res);
++ router.get('/s/:space/settings', wrap(async (req, res) => {
++ const space = await locateSpace(req, res);
if (!space) return;
if (!svc.access.canManage(space, req.actor)) return req.actor.subject ? errorPage(req, res, 403, 'Owners only', 'Only an owner of this space can change its settings.') : needSignIn(req, res, 'Sign in as an owner of this space.');
-- send(req, res, 200, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
-- });
++ send(req, res, 200, views.spaceSettingsPage({ space, roles: await svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
++ }));
router.post('/s/:space/settings', B('wiki.space.update'), form, wrap(async (req, res) => {
-- const space = locateSpace(req, res);
++ const space = await locateSpace(req, res);
if (!space) return;
const b = req.body || {};
try {
-- if (b.op === 'role') svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);
-- else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }
-- else { const s = svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }
++ if (b.op === 'role') await svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);
++ else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); await svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }
++ else { const s = await svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/settings?saved=1`);
} catch (err) {
if (!err.status || err.status >= 500) throw err;
if (!svc.access.canManage(space, req.actor)) return errorPage(req, res, err.status, 'Owners only', err.message);
-- send(req, res, err.status, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), error: err.message }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
++ send(req, res, err.status, views.spaceSettingsPage({ space, roles: await svc.roles(space.id, req.actor), error: err.message }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
}
}));
@@@ -234,13 +236,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
else errorPage(req, res, 403, 'Owners only', 'Only an owner of this space imports pages into it.');
return false;
}
-- router.get('/s/:space/import', (req, res) => {
-- const space = locateSpace(req, res);
++ router.get('/s/:space/import', wrap(async (req, res) => {
++ const space = await locateSpace(req, res);
if (!space || !ownersOnly(req, res, space)) return;
send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });
-- });
++ }));
router.post('/s/:space/import', B('wiki.page.import'), importForm, wrap(async (req, res) => {
-- const space = locateSpace(req, res);
++ const space = await locateSpace(req, res);
if (!space || !ownersOnly(req, res, space)) return;
const b = req.body || {};
const values = { bundle: String(b.bundle || ''), publish: b.publish === '1', on_existing: b.on_existing, source: b.source, ai_assisted: b.ai_assisted === '1' };
@@@ -249,9 +251,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
const parsed = importer.parseBundleText(values.bundle);
const input = Array.isArray(parsed) ? { pages: parsed } : { ...parsed };
Object.assign(input, { publish: values.publish, on_existing: values.on_existing || 'fail', source: values.source || input.source || null, ai_assisted: values.ai_assisted });
-- const { bundle } = svc.prepareImport(space.id, input, req.actor);
++ const { bundle } = await svc.prepareImport(space.id, input, req.actor);
await resolveBundleCitations(bundle, platform);
-- const out = svc.importPages(space.id, bundle, req.actor);
++ const out = await svc.importPages(space.id, bundle, req.actor);
again(200, null, out);
} catch (err) {
if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
@@@ -259,19 +261,20 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}));
-- router.get('/s/:space/proposals', (req, res) => {
-- const space = locateSpace(req, res);
++ router.get('/s/:space/proposals', wrap(async (req, res) => {
++ const space = await locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
-- const items = svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id).map((proposal) => ({ proposal, page: svc.pageById(proposal.page_id) }));
++ const items = [];
++ for (const proposal of (await svc.pendingProposals(req.actor)).filter((p) => p.space_id === space.id)) items.push({ proposal, page: await svc.pageById(proposal.page_id) });
send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });
-- });
++ }));
router.post('/proposals/:id', B('wiki.revision.publish'), form, wrap(async (req, res) => {
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');
-- const p = svc.getProposal(req.params.id);
++ const p = await svc.getProposal(req.params.id);
if (!p) return notFound(req, res);
-- svc.reviewProposal(p.id, { decision: (req.body || {}).decision, publish: true }, req.actor);
-- const space = svc.spaceById(p.space_id);
++ await svc.reviewProposal(p.id, { decision: (req.body || {}).decision, publish: true }, req.actor);
++ const space = await svc.spaceById(p.space_id);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/proposals`);
}));
@@@ -307,13 +310,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
router.get('/w/:space/:slug', wrap(async (req, res) => {
const json = /\.json$/.test(req.params.slug);
if (json) req.params.slug = req.params.slug.slice(0, -5);
-- const found = locate(req, res, json ? '.json' : '');
++ const found = await locate(req, res, json ? '.json' : '');
if (!found) return;
const { space, page } = found;
const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
let v;
-- try { v = svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
++ try { v = await svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';
const cache = open && v.isPublishedRevision ? 'public' : null;
if (json) {
@@@ -321,9 +324,16 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
.set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));
return;
}
-- const resolve = (t) => svc.resolveLink(t, req.actor);
-- const bodyHtml = svc.renderRevision(space, v.revision, req.actor);
-- const discussion = v.isPublishedRevision ? await discussionFor(space, page, req.actor) : { state: 'not_public' };
++ // Infobox values of type page link to that page: resolved before the (synchronous) view renders.
++ const infoboxLinks = new Map();
++ for (const x of v.infobox) {
++ if (x.type !== 'page' || infoboxLinks.has(x.value)) continue;
++ infoboxLinks.set(x.value, await svc.resolveLink(content.parseTarget(x.value, space.slug) || { space: space.slug, slug: '-', title: x.value }, req.actor));
++ }
++ const [bodyHtml, discussion] = await Promise.all([
++ svc.renderRevision(space, v.revision, req.actor),
++ v.isPublishedRevision ? discussionFor(space, page, req.actor) : { state: 'not_public' },
++ ]);
const url = svc.pageUrl(space, page);
const crumbsLd = [{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }, { name: v.revision.fields.title, url }];
// The gate decides robots for the published revision; any other revision is noindex.
@@@ -338,82 +348,82 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
seo.structuredData.breadcrumbs(crumbsLd),
].filter(Boolean) : null;
const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
-- send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => resolve(content.parseTarget(title, space.slug) || { space: space.slug, slug: '-', title }), flash: req.query.saved ? 'Saved.' : null }), {
++ send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {
head, path: svc.pagePath(space, page), cache,
});
}));
-- router.get('/w/:space/:slug/history', (req, res) => {
-- const found = locate(req, res, '/history');
++ router.get('/w/:space/:slug/history', wrap(async (req, res) => {
++ const found = await locate(req, res, '/history');
if (!found) return;
const { space, page } = found;
-- send(req, res, 200, views.historyPage({ space, page, list: svc.history(page, { limit: 500, actor: req.actor }), canEdit: svc.access.canEdit(space, req.actor) }), {
++ send(req, res, 200, views.historyPage({ space, page, list: await svc.history(page, { limit: 500, actor: req.actor }), canEdit: svc.access.canEdit(space, req.actor) }), {
title: `History of ${page.title}`, robots: 'noindex, follow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
});
-- });
++ }));
// The citation inspector: one revision's sources (?rev=N; the published one by default), with the
// same read rules as the article.
-- router.get('/w/:space/:slug/sources', (req, res) => {
-- const found = locate(req, res, '/sources');
++ router.get('/w/:space/:slug/sources', wrap(async (req, res) => {
++ const found = await locate(req, res, '/sources');
if (!found) return;
const { space, page } = found;
const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
let inspected;
-- try { inspected = svc.citationInspector(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
++ try { inspected = await svc.citationInspector(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
send(req, res, 200, views.sourcesPage({ space, page, ...inspected }), {
title: `Sources of ${inspected.view.revision.fields.title || page.title}`, robots: 'noindex, follow',
cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
});
-- });
++ }));
-- router.get('/w/:space/:slug/compare', (req, res) => {
-- const found = locate(req, res);
++ router.get('/w/:space/:slug/compare', wrap(async (req, res) => {
++ const found = await locate(req, res);
if (!found) return;
const a = Number(req.query.a), b = Number(req.query.b);
if (!Number.isInteger(a) || !Number.isInteger(b)) return errorPage(req, res, 400, 'Pick two revisions', 'Choose a "from" and a "to" revision to compare.');
res.redirect(303, `${views.wpath(found.space, found.page)}/diff/${Math.min(a, b)}/${Math.max(a, b)}`);
-- });
++ }));
-- router.get('/w/:space/:slug/diff/:a/:b', (req, res) => {
-- const found = locate(req, res, `/diff/${req.params.a}/${req.params.b}`);
++ router.get('/w/:space/:slug/diff/:a/:b', wrap(async (req, res) => {
++ const found = await locate(req, res, `/diff/${req.params.a}/${req.params.b}`);
if (!found) return;
const { space, page } = found;
let diff;
-- try { diff = svc.diff(page, req.params.a, req.params.b, { mode: req.query.mode, actor: req.actor }); } catch (err) { if (err.status === 404 || err.status === 422) return notFound(req, res); throw err; }
++ try { diff = await svc.diff(page, req.params.a, req.params.b, { mode: req.query.mode, actor: req.actor }); } catch (err) { if (err.status === 404 || err.status === 422) return notFound(req, res); throw err; }
send(req, res, 200, views.diffPage({ space, page, diff }), {
title: `Changes to ${page.title}`, robots: 'noindex, nofollow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
});
-- });
++ }));
// Editing a missing page (a red link) opens the new-page form with the title filled in.
-- function editTarget(req, res) {
-- const space = svc.findSpace(req.params.space);
-- if (space && !space.deleted_at && svc.access.canReadSpace(space, req.actor) && !svc.findPage(space.id, req.params.slug) && !svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`)) {
++ async function editTarget(req, res) {
++ const space = await svc.findSpace(req.params.space);
++ if (space && !space.deleted_at && svc.access.canReadSpace(space, req.actor) && !await svc.findPage(space.id, req.params.slug) && !await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`)) {
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/new?title=${encodeURIComponent(String(req.query.title || req.params.slug))}`);
return null;
}
return locate(req, res, '/edit');
}
-- router.get('/w/:space/:slug/edit', (req, res) => {
-- const found = editTarget(req, res);
++ router.get('/w/:space/:slug/edit', wrap(async (req, res) => {
++ const found = await editTarget(req, res);
if (!found) return;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
// Start from the newest revision that is not an unapproved AI proposal; concurrency is
// still checked against the head.
-- const list = svc.history(page, { limit: 500 });
++ const list = await svc.history(page, { limit: 500 });
const headNumber = list[0].number;
const start = list.find((r) => !r.proposal || r.proposal.status === 'approved') || list[0];
-- const from = svc.view(space, page, req.actor, { revision: start.number });
++ const from = await svc.view(space, page, req.actor, { revision: start.number });
const note = start.number !== headNumber ? `The newest revision (${headNumber}) is an AI proposal that has not been approved; this form starts from revision ${start.number}.` : null;
send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
-- });
++ }));
router.post('/w/:space/:slug/edit', B('wiki.page.edit'), form, wrap(async (req, res) => {
-- const found = locate(req, res, '/edit');
++ const found = await locate(req, res, '/edit');
if (!found) return;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
@@@ -422,15 +432,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
const base = Number(b.base_revision) || expected;
const keep = [].concat(b.keep_citation || []).map(String);
let baseView = null;
-- try { baseView = Number.isInteger(base) && base > 0 ? svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }
++ try { baseView = Number.isInteger(base) && base > 0 ? await svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, message: b.message, keep };
const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, page, values, error, preview, citations: baseView ? baseView.citations : [], baseRevision: base, expectedRevision: expected }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
try {
const infobox = content.parseInfoboxText(b.infobox);
-- if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
++ if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
const cites = await resolveCitations(citationsFromForm(b), platform);
-- const out = svc.editPage(page.id, { expectedRevision: expected, baseRevision: base, title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, citations: cites, keepCitations: keep, message: b.message || null }, req.actor);
-- if (b.op === 'publish') svc.publish(page.id, { revision: out.revision.number }, req.actor);
++ const out = await svc.editPage(page.id, { expectedRevision: expected, baseRevision: base, title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, citations: cites, keepCitations: keep, message: b.message || null }, req.actor);
++ if (b.op === 'publish') await svc.publish(page.id, { revision: out.revision.number }, req.actor);
res.redirect(303, `${views.wpath(space, out.page)}${b.op === 'publish' ? '' : `?rev=${out.revision.number}`}`);
} catch (err) {
if (err.code === 'revision.conflict') return again(409, `Someone saved revision ${err.extra ? err.extra.current : ''} while you were editing. Your text is below; open the page in another tab, merge by hand, and save again.`);
@@@ -439,52 +449,52 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}));
-- router.get('/w/:space/:slug/revert', (req, res) => {
-- const found = locate(req, res, '/revert');
++ router.get('/w/:space/:slug/revert', wrap(async (req, res) => {
++ const found = await locate(req, res, '/revert');
if (!found) return;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
const to = Number(req.query.to);
-- const head = svc.history(page, { limit: 1 })[0].number;
++ const head = (await svc.history(page, { limit: 1 }))[0].number;
if (!Number.isInteger(to) || to < 1 || to > head) return notFound(req, res);
send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });
-- });
++ }));
router.post('/w/:space/:slug/revert', B('wiki.page.edit'), form, wrap(async (req, res) => {
-- const found = locate(req, res, '/revert');
++ const found = await locate(req, res, '/revert');
if (!found) return;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
const b = req.body || {};
-- const out = svc.revert(page.id, { toRevision: Number(b.to), expectedRevision: Number(b.expected_revision), message: b.message || null }, req.actor);
++ const out = await svc.revert(page.id, { toRevision: Number(b.to), expectedRevision: Number(b.expected_revision), message: b.message || null }, req.actor);
res.redirect(303, out.published ? views.wpath(space, out.page) : `${views.wpath(space, out.page)}?rev=${out.revision.number}`);
}));
-- router.get('/w/:space/:slug/publish', (req, res) => {
-- const found = locate(req, res, '/publish');
++ router.get('/w/:space/:slug/publish', wrap(async (req, res) => {
++ const found = await locate(req, res, '/publish');
if (!found) return;
if (!editorsOnly(req, res, found.space)) return;
const rev = Number(req.query.rev);
if (!Number.isInteger(rev)) return notFound(req, res);
send(req, res, 200, views.confirmPublishPage({ ...found, rev }), { title: `Publish ${found.page.title}`, robots: 'noindex, nofollow' });
-- });
++ }));
-- router.get('/w/:space/:slug/settings', (req, res) => {
-- const found = locate(req, res, '/settings');
++ router.get('/w/:space/:slug/settings', wrap(async (req, res) => {
++ const found = await locate(req, res, '/settings');
if (!found) return;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
-- renderPageSettings(req, res, space, page, 200, { flash: req.query.saved ? (req.query.saved === 'verify' ? `Checked attachments against OpenVibe.Media: ${req.query.summary || ''}` : 'Saved.') : null });
-- });
++ await renderPageSettings(req, res, space, page, 200, { flash: req.query.saved ? (req.query.saved === 'verify' ? `Checked attachments against OpenVibe.Media: ${req.query.summary || ''}` : 'Saved.') : null });
++ }));
-- function renderPageSettings(req, res, space, page, status, extra = {}) {
-- const view = svc.view(space, page, req.actor, {});
-- const headNumber = svc.history(page, { limit: 1 })[0].number;
-- send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents: parentsOf(space, req.actor), canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
++ async function renderPageSettings(req, res, space, page, status, extra = {}) {
++ const [view, list, parents] = await Promise.all([svc.view(space, page, req.actor, {}), svc.history(page, { limit: 1 }), parentsOf(space, req.actor)]);
++ const headNumber = list[0].number;
++ send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents, canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
}
router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {
-- const found = locate(req, res, '/settings');
++ const found = await locate(req, res, '/settings');
if (!found) return;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
@@@ -492,51 +502,51 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
const back = (p = page, q = 'saved=1') => res.redirect(303, `${views.wpath(space, p)}/settings?${q}`);
try {
switch (b.op) {
-- case 'publish': svc.publish(page.id, { revision: Number(b.revision) }, req.actor); return res.redirect(303, views.wpath(space, page));
++ case 'publish': await svc.publish(page.id, { revision: Number(b.revision) }, req.actor); return res.redirect(303, views.wpath(space, page));
case 'schedule': {
const at = Date.parse(/Z|[+-]\d\d:?\d\d$/.test(String(b.run_at)) ? b.run_at : `${b.run_at}Z`);
-- svc.schedulePublish(page.id, { revision: Number(b.revision), runAt: at }, req.actor);
++ await svc.schedulePublish(page.id, { revision: Number(b.revision), runAt: at }, req.actor);
return back();
}
-- case 'unpublish': svc.unpublish(page.id, req.actor); return back();
-- case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
++ case 'unpublish': await svc.unpublish(page.id, req.actor); return back();
++ case 'move': { const p = await svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
case 'attach': await svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor, { describe: platform.media.describe }); return back();
-- case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
++ case 'detach': await svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
case 'verify': {
const results = await svc.verifyMedia(page.id, platform.media.resolve);
const count = (k) => results.filter((r) => r.outcome === k).length;
return back(page, `saved=verify&summary=${encodeURIComponent(`${count('available')} available, ${count('broken')} broken, ${count('check_failed')} not checked (Media did not answer)`)}`);
}
-- case 'visibility': svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex === '1' }, req.actor); return back();
-- case 'delete': if (b.confirm !== 'yes') throw new svc.WikiError(422, 'page.confirm', 'Tick the box to confirm'); svc.deletePage(page.id, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
++ case 'visibility': await svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex === '1' }, req.actor); return back();
++ case 'delete': if (b.confirm !== 'yes') throw new svc.WikiError(422, 'page.confirm', 'Tick the box to confirm'); await svc.deletePage(page.id, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');
}
} catch (err) {
if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
-- renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });
++ await renderPageSettings(req, res, space, await svc.pageById(page.id), err.status, { error: err.message });
}
}));
router.post('/w/:space/:slug/review', B('wiki.revision.publish'), form, wrap(async (req, res) => {
-- const found = locate(req, res, '/review');
++ const found = await locate(req, res, '/review');
if (!found) return;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
const b = req.body || {};
-- svc.reviewRevision(space.id, page.slug, Number(b.revision), { decision: b.decision, note: b.note ? String(b.note).slice(0, 2000) : null }, req.actor);
++ await svc.reviewRevision(space.id, page.slug, Number(b.revision), { decision: b.decision, note: b.note ? String(b.note).slice(0, 2000) : null }, req.actor);
res.redirect(303, `${views.wpath(space, page)}/history`);
}));
router.post('/w/:space/:slug/watch', B('wiki.page.watch'), form, wrap(async (req, res) => {
-- const found = locate(req, res, '/watch');
++ const found = await locate(req, res, '/watch');
if (!found) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');
-- svc.watch(found.page.id, req.actor, (req.body || {}).on !== '0');
++ await svc.watch(found.page.id, req.actor, (req.body || {}).on !== '0');
res.redirect(303, views.wpath(found.space, found.page));
}));
router.post('/w/:space/:slug/discuss', B('wiki.discussion.comment'), form, wrap(async (req, res) => {
-- const found = locate(req, res, '/discuss');
++ const found = await locate(req, res, '/discuss');
if (!found) return;
const { space, page } = found;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to comment.');
ddiff --git a/server/index.js b/server/index.js
iindex 3c018ca..eff3ac0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -9,25 +9,30 @@
* Events outbox relay, the Media attachment check) run only when asked (`workers: true`).
*/
const { load } = require('./config');
--const { openDb, createStores } = require('./db');
++const { openDb, migrate, createStores } = require('./db');
const { createPlatform } = require('./integrations/platform');
const { createWikiService } = require('./wiki/service');
const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
--async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {
++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {
config = config || load();
-- const db = openDb(config.dbPath);
++ // PostgreSQL (ADR-035): migrate on the owner's direct connection, then serve on the pooled runtime role.
++ // Tests hand in a migrated database of their own (test/db-helper.js).
++ const db = givenDb || openDb(config, { log });
++ if (!givenDb) await migrate(config, { serving: db, log });
const stores = createStores(db, { now });
++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
++ const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null;
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log });
-- const reconciled = svc.reconcileIndex();
++ const reconciled = await svc.reconcileIndex();
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
-- const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl, limitsNow });
++ const app = createApp({ config, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow });
const timers = [];
if (workers) {
@@@ -44,6 +49,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
timers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));
}
if (platform.eventsConfigured) platform.outbox.start();
++ timers.push(setInterval(() => platform.outbox.refreshCounts().catch(() => {}), 30 * 1000));
// wiki.projects on Network (Contracts 0.41.0): people whose spaces or roles changed, every minute.
const projects = require('./integrations/projects-module').createProjectsModule({ db, config, tokens: platform.tokenClient, fetchImpl, now, log });
if (projects.enabled) timers.push(setInterval(() => projects.drain().catch((err) => log.warn(`[Wiki] wiki.projects: ${err.message}`)), 60 * 1000));
@@@ -61,7 +67,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
for (const t of timers) clearInterval(t);
await platform.outbox.stop();
if (server) await new Promise((resolve) => server.close(resolve));
-- db.close();
++ if (!givenDb) await db.close();
++ if (valkey) await valkey.close().catch(() => {});
}
return { app, db, svc, stores, platform, keys, viewers, server, config, stop };
}
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
iindex 3c3ba1d..fd35f83 100644
---- a/server/integrations/platform.js
++++ b/server/integrations/platform.js
@@@ -10,7 +10,7 @@
*/
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
const { createDiscussionClient } = require('openvibe-publishing/discussion');
class IntegrationError extends Error {
@@@ -42,11 +42,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
onWarning: (msg) => log.warn(`[Wiki] ${msg}`),
});
const events = createEventsClient(sdk, { source: 'wiki' });
-- const outbox = createOutbox(db, {
++ // The PostgreSQL outbox (openvibe-sdk/events): rows are written in the change's own transaction
++ // (outbox.enqueue(db, …) joins the ambient transaction); several processes relay one table safely (leases).
++ const outbox = createPgOutbox(db, {
events, table: 'wiki_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,
onError: (err) => log.warn(`[Wiki] event relay: ${err && err.message}`),
});
-- outbox.ensureSchema();
++ // Counts for metrics (read synchronously at scrape time), refreshed by readiness and a timer.
++ const outboxCounts = { pending: 0, rejected: 0 };
++ outbox.refreshCounts = async () => {
++ const [pending, rejected] = await Promise.all([outbox.pending(), outbox.rejected()]);
++ Object.assign(outboxCounts, { pending, rejected });
++ return { ...outboxCounts };
++ };
++ outbox.counts = () => ({ ...outboxCounts });
async function getJson(url, audience, { headers = {}, timeoutMs = 5000 } = {}) {
let res;
ddiff --git a/server/integrations/projects-module.js b/server/integrations/projects-module.js
iindex d46a3e9..0bf1e6c 100644
---- a/server/integrations/projects-module.js
++++ b/server/integrations/projects-module.js
@@@ -16,12 +16,12 @@ const NS = 'wiki.projects';
const MAX_SPACES = 50;
/** The record for one person: { spaces: [{ slug, name, role }], private_count }. */
--function summarize(db, subject) {
-- const rows = db.prepare(`SELECT s.slug, s.name, s.visibility,
++async function summarize(db, subject) {
++ const rows = await db.prepare(`SELECT s.slug, s.name, s.visibility,
CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 'owner' ELSE p.role END AS role
FROM wiki_spaces s LEFT JOIN wiki_permissions p ON p.space_id = s.id AND p.subject = @subject
WHERE s.deleted_at IS NULL AND (s.owner = @subject OR p.role IN ('owner', 'editor'))
-- ORDER BY role = 'owner' DESC, s.name COLLATE NOCASE`).all({ subject });
++ ORDER BY (CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 0 ELSE 1 END), lower(s.name), s.id`).all({ subject });
const pub = rows.filter((r) => r.visibility === 'public');
return {
spaces: pub.slice(0, MAX_SPACES).map((r) => ({ slug: r.slug, name: String(r.name).slice(0, 120), role: r.role })),
@@@ -48,16 +48,16 @@ function createProjectsModule({ db, config, tokens, fetchImpl = globalThis.fetch
draining = true;
let written = 0;
try {
-- for (const { subject, marked_at: markedAt } of db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at LIMIT ?').all(limit)) {
++ for (const { subject, marked_at: markedAt } of await db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at, subject LIMIT ?').all(limit)) {
const clear = () => db.prepare('DELETE FROM wiki_module_dirty WHERE subject = ? AND marked_at = ?').run(subject, markedAt);
-- const data = summarize(db, subject);
++ const data = await summarize(db, subject);
const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
-- const last = db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);
-- if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; clear(); continue; }
++ const last = await db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);
++ if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; await clear(); continue; }
try { await put(subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; continue; }
-- db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)
++ await db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)
ON CONFLICT (subject) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash, now());
-- clear();
++ await clear();
stats.written++; written++;
}
} finally { draining = false; }
ddiff --git a/server/wiki/access.js b/server/wiki/access.js
iindex 5663c35..de92167 100644
---- a/server/wiki/access.js
++++ b/server/wiki/access.js
@@@ -18,6 +18,10 @@
* records the answers on the actor (actor.vipAllowed, actor.vipDenied). The synchronous checks below then
* read them. Anything not prepared is not readable (fail closed), so VIP content never leaks into a list,
* a link preview or a feed that did not ask.
++ * Roles are read the same way (PostgreSQL, ADR-035): loadRoles(actor) reads every role the actor's subject holds
++ * (one query, cached on the actor as actor.wikiRoles) before any check, so the checks stay synchronous and a
++ * filter over pages never sees a promise. A check on an actor whose roles were not loaded throws (a bug, never
++ * a silent refusal or grant). The service loads them at the top of every method that takes an actor.
* Roles: owner (everything, incl. roles and settings), editor (write revisions, publish, revert,
* review AI proposals), viewer (read a private space). Network staff (admin, global_mod) act as
* owner of official spaces; they get no silent access to user spaces.
@@@ -40,7 +44,27 @@ function effectiveVisibility(space, page) {
}
function createAccess(db, { vip = null } = {}) {
-- const roleOf = db.prepare('SELECT role FROM wiki_permissions WHERE space_id = ? AND subject = ?');
++ const rolesOfSubject = db.prepare('SELECT space_id, role FROM wiki_permissions WHERE subject = ?');
++ const rolesOfSpace = db.prepare('SELECT subject, role FROM wiki_permissions WHERE space_id = ?');
++
++ /** Read (or re-read with fresh: true) every role the actor holds; idempotent and cheap to call again. */
++ async function loadRoles(actor, { fresh = false } = {}) {
++ if (!actor || actor.kind === 'system' || !actor.subject) return actor;
++ if (actor.wikiRoles && !fresh) return actor;
++ const rows = await rolesOfSubject.all(actor.subject);
++ Object.defineProperty(actor, 'wikiRoles', { value: new Map(rows.map((r) => [r.space_id, r.role])), writable: true, configurable: true, enumerable: false });
++ return actor;
++ }
++
++ /** Actors for subjects of one space (watchers), with that space's roles already loaded. */
++ async function subjectActors(space) {
++ const roles = new Map((await rolesOfSpace.all(space.id)).map((r) => [r.subject, r.role]));
++ return (subject) => {
++ const a = { kind: 'user', subject, staff: false };
++ Object.defineProperty(a, 'wikiRoles', { value: new Map(roles.has(subject) ? [[space.id, roles.get(subject)]] : []), enumerable: false });
++ return a;
++ };
++ }
/** 'owner' | 'editor' | 'viewer' | null */
function role(space, actor) {
@@@ -50,8 +74,8 @@ function createAccess(db, { vip = null } = {}) {
if (actor.kind === 'user' && actor.staff && space.kind === 'official') return 'owner';
if (!subject) return null;
if (space.owner === subject) return 'owner';
-- const r = roleOf.get(space.id, subject);
-- return r ? r.role : null;
++ if (!actor.wikiRoles) throw new Error('wiki access: roles not loaded for this actor (await access.loadRoles(actor) first)');
++ return actor.wikiRoles.get(space.id) || null;
}
function isPerson(actor) { return !!(actor && actor.subject && /^usr_/.test(actor.subject)); }
@@@ -68,6 +92,8 @@ function createAccess(db, { vip = null } = {}) {
const api = {
VISIBILITIES,
role,
++ loadRoles,
++ subjectActors,
isPerson,
effectiveVisibility,
canReadSpace(space, actor) {
@@@ -121,9 +147,9 @@ function createAccess(db, { vip = null } = {}) {
return { reason: (actor.vipDenied && actor.vipDenied.get(vipKey(space, page))) || (actor.subject ? 'denied' : 'not_signed_in'),
owner: space.owner || null, joinUrl: vip ? vip.joinUrl(space.owner || null) : null };
},
-- /** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. */
-- subjectCanRead(space, page, subject) {
-- return api.canReadPage(space, page, { kind: 'user', subject, staff: false });
++ /** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. actorFor comes from subjectActors(space). */
++ subjectCanRead(space, page, subject, actorFor) {
++ return api.canReadPage(space, page, actorFor(subject));
},
};
return api;
ddiff --git a/server/wiki/seed.js b/server/wiki/seed.js
iindex 421c046..9c49f63 100644
---- a/server/wiki/seed.js
++++ b/server/wiki/seed.js
@@@ -23,11 +23,11 @@ const IMPORTED = {
},
};
--function seed(svc, data, { publish = true, log = console } = {}) {
++async function seed(svc, data, { publish = true, log = console } = {}) {
if (!data || !data.space || !Array.isArray(data.pages)) throw new TypeError('seed data needs { space, pages }');
-- let space = svc.findSpace(data.space.slug);
++ let space = await svc.findSpace(data.space.slug);
if (!space) {
-- space = svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);
++ space = await svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);
log.log(`[seed] created space ${space.slug}`);
}
const bySlug = new Map();
@@@ -39,27 +39,27 @@ function seed(svc, data, { publish = true, log = console } = {}) {
const next = [];
for (const p of pending) {
const slug = pageSlug(p.title);
-- const existing = svc.findPage(space.id, slug);
++ const existing = await svc.findPage(space.id, slug);
if (existing) { bySlug.set(slug, existing); skipped.push(p.title); continue; }
let parentId = null;
if (p.parent) {
-- const parent = bySlug.get(pageSlug(p.parent)) || svc.findPage(space.id, pageSlug(p.parent));
++ const parent = bySlug.get(pageSlug(p.parent)) || await svc.findPage(space.id, pageSlug(p.parent));
if (!parent) { next.push(p); continue; }
parentId = parent.id;
}
-- const out = svc.createPage(space.id, {
++ const out = await svc.createPage(space.id, {
title: p.title, body: p.body, summary: p.summary || null, infobox: p.infobox || [], parentId,
citations: (p.citations || []).map((c) => ({ url: c.url, title: c.title || null, retrievedAt: c.retrievedAt, quote: c.quote ? { text: c.quote } : null, licenseNote: c.licenseNote || null })),
message: 'Seed import', authorship: IMPORTED,
}, SYSTEM);
-- if (publish) svc.publish(out.page.id, { revision: out.revision.number }, SYSTEM);
-- bySlug.set(slug, svc.pageById(out.page.id));
++ if (publish) await svc.publish(out.page.id, { revision: out.revision.number }, SYSTEM);
++ bySlug.set(slug, await svc.pageById(out.page.id));
created.push(p.title);
}
pending = next;
}
if (pending.length) throw new Error(`seed: parents not found for ${pending.map((p) => p.title).join(', ')}`);
-- return { space: svc.findSpace(data.space.slug), created, skipped };
++ return { space: await svc.findSpace(data.space.slug), created, skipped };
}
module.exports = { seed, SYSTEM, IMPORTED };
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex 8f581a5..9e6ae9b 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -4,14 +4,16 @@
* attachments, watchers and AI proposals. Wiki owns its publication state; the Publishing
* packages supply the mechanics (revisions, citations, the gate, redirects, index hooks).
*
-- * Every write runs in one SQLite transaction together with the events it causes (transactional
-- * outbox): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,
++ * Every write runs in one PostgreSQL transaction together with the events it causes (transactional
++ * outbox; ambient transactions, openvibe-sdk ≥ 0.18): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,
* wiki.watch.triggered, and the Search index events wiki.index_document.upserted|deleted.
* After the commit (never inside it), a page that stops or starts being public has its Community
* discussion thread hidden or shown again (best effort).
*
-- * Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code).
++ * Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code). Every method that
++ * reads or writes data is async (ADR-035); pure helpers (paths, linkRel) stay synchronous.
*/
++const { AsyncLocalStorage } = require('node:async_hooks');
const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
@@@ -73,25 +75,31 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const access = createAccess(db, { vip });
const origin = config.baseUrl;
const policy = config.gate;
++ // Every entry point reads the actor's roles again (one indexed query): a role granted a moment ago counts, as
++ // it did when each check read SQLite.
++ const roles = (actor, opts = { fresh: true }) => access.loadRoles(actor, opts);
-- // ── Queries ───────────────────────────────────────────────
++ // ── Queries (PostgreSQL; better-sqlite3-shaped async statements, openvibe-sdk/db prepare) ──
const q = {
spaceById: db.prepare('SELECT * FROM wiki_spaces WHERE id = ?'),
++ spacesByIds: db.prepare('SELECT * FROM wiki_spaces WHERE id = ANY(?)'),
spaceBySlug: db.prepare('SELECT * FROM wiki_spaces WHERE slug = ?'),
-- spaces: db.prepare('SELECT * FROM wiki_spaces WHERE deleted_at IS NULL ORDER BY kind = \'official\' DESC, name COLLATE NOCASE'),
++ spaces: db.prepare("SELECT * FROM wiki_spaces WHERE deleted_at IS NULL ORDER BY (kind = 'official') DESC, lower(name), id"),
insertSpace: db.prepare(`INSERT INTO wiki_spaces (id, slug, name, description, kind, visibility, owner, created_by, created_at, updated_at)
VALUES (@id, @slug, @name, @description, @kind, @visibility, @owner, @created_by, @now, @now)`),
pageById: db.prepare('SELECT * FROM wiki_pages WHERE id = ?'),
pageBySlug: db.prepare('SELECT * FROM wiki_pages WHERE space_id = ? AND slug = ?'),
-- pagesOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),
-- publishedOfSpace: db.prepare("SELECT id FROM wiki_pages WHERE space_id = ? AND state IN ('published','unpublished','scheduled','draft')"),
++ pagesOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state <> 'deleted' ORDER BY position, lower(title), id"),
++ publishedOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state IN ('published','unpublished','scheduled','draft')"),
insertPage: db.prepare(`INSERT INTO wiki_pages (id, space_id, slug, title, parent_id, position, state, visibility, created_by, created_at, updated_at)
VALUES (@id, @space_id, @slug, @title, @parent_id, @position, 'draft', @visibility, @created_by, @now, @now)`),
-- children: db.prepare("SELECT * FROM wiki_pages WHERE parent_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),
-- insertLink: db.prepare('INSERT OR IGNORE INTO wiki_page_links (from_page_id, from_revision, target_space, target_slug, label) VALUES (?, ?, ?, ?, ?)'),
++ children: db.prepare("SELECT * FROM wiki_pages WHERE parent_id = ? AND state <> 'deleted' ORDER BY position, lower(title), id"),
++ insertLink: db.prepare('INSERT INTO wiki_page_links (from_page_id, from_revision, target_space, target_slug, label) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING'),
linksOf: db.prepare('SELECT * FROM wiki_page_links WHERE from_page_id = ? AND from_revision = ? ORDER BY target_space, target_slug'),
-- backlinks: db.prepare(`SELECT DISTINCT p.* FROM wiki_page_links l JOIN wiki_pages p ON p.id = l.from_page_id AND l.from_revision = p.published_revision
-- WHERE l.target_space = ? AND l.target_slug = ? AND p.state = 'published' ORDER BY p.title COLLATE NOCASE LIMIT 200`),
++ backlinks: db.prepare(`SELECT p.* FROM wiki_pages p
++ WHERE p.state = 'published' AND EXISTS (SELECT 1 FROM wiki_page_links l WHERE l.from_page_id = p.id AND l.from_revision = p.published_revision
++ AND l.target_space = ? AND l.target_slug = ?)
++ ORDER BY lower(p.title), p.id LIMIT 200`),
insertInfobox: db.prepare(`INSERT INTO wiki_infobox_values (page_id, revision, position, key, label, type, value_text, value_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
infoboxOf: db.prepare('SELECT * FROM wiki_infobox_values WHERE page_id = ? AND revision = ? ORDER BY position'),
@@@ -100,20 +108,20 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),
delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),
// wiki.projects (Network user module): who needs their record written again.
-- dirtySubject: db.prepare("INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at"),
++ dirtySubject: db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at'),
dirtySpace: db.prepare(`INSERT INTO wiki_module_dirty (subject, marked_at)
-- SELECT subject, @t FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'
-- UNION SELECT owner, @t FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'
++ SELECT subject, @t::bigint FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'
++ UNION SELECT owner, @t::bigint FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'
ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at`),
owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),
watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),
-- watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),
++ watch: db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING'),
unwatch: db.prepare('DELETE FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
-- isWatching: db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
++ isWatching: db.prepare('SELECT 1 AS yes FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
proposal: db.prepare('SELECT * FROM wiki_ai_proposals WHERE id = ?'),
proposalByRev: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? AND revision = ?'),
-- proposalsOfPage: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? ORDER BY created_at DESC'),
-- pendingProposals: db.prepare("SELECT * FROM wiki_ai_proposals WHERE status = 'pending' ORDER BY created_at DESC LIMIT 200"),
++ proposalsOfPage: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? ORDER BY created_at DESC, id DESC'),
++ pendingProposals: db.prepare("SELECT * FROM wiki_ai_proposals WHERE status = 'pending' ORDER BY created_at DESC, id DESC LIMIT 200"),
insertProposal: db.prepare(`INSERT INTO wiki_ai_proposals (id, page_id, space_id, revision, base_revision, workflow_id, run_id, stub_provider, status, proposed_by, note, created_at)
VALUES (@id, @page_id, @space_id, @revision, @base_revision, @workflow_id, @run_id, @stub_provider, 'pending', @proposed_by, @note, @now)`),
reviewProposal: db.prepare("UPDATE wiki_ai_proposals SET status = @status, reviewed_by = @by, review_note = @note, reviewed_at = @now WHERE id = @id AND status = 'pending'"),
@@@ -122,15 +130,24 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
-- ORDER BY p.id`),
++ ORDER BY p.id LIMIT 50000`),
insertOrigin: db.prepare(`INSERT INTO wiki_attachment_origins (attachment_id, page_id, media_id, attached_by, media_owner, media_visibility, attached_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
originsOf: db.prepare('SELECT * FROM wiki_attachment_origins WHERE page_id = ?'),
++ // Case-insensitive, as SQLite's LIKE was for ASCII.
search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
JOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision
-- WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)
-- AND (p.title LIKE @like ESCAPE '\\' OR r.content LIKE @like ESCAPE '\\')
-- ORDER BY (p.title LIKE @like ESCAPE '\\') DESC, p.revision_published_at DESC LIMIT 200`),
++ WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space::text IS NULL OR s.slug = @space)
++ AND (p.title ILIKE @like ESCAPE '\' OR r.content ILIKE @like ESCAPE '\')
++ ORDER BY (p.title ILIKE @like ESCAPE '\') DESC, p.revision_published_at DESC, p.id LIMIT 200`),
++ setPublished: db.prepare(`UPDATE wiki_pages SET state = 'published', published_revision = @n, title = @title, published_at = COALESCE(published_at, @t),
++ revision_published_at = CASE WHEN published_revision IS NOT DISTINCT FROM @n AND state = 'published' THEN revision_published_at ELSE @t END,
++ updated_at = @t WHERE id = @id`),
++ setState: db.prepare('UPDATE wiki_pages SET state = ?, updated_at = ? WHERE id = ?'),
++ touch: db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?'),
++ vipPages: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND visibility = 'vip' AND state <> 'deleted' ORDER BY id LIMIT 50"),
++ pageIds: db.prepare('SELECT id FROM wiki_pages ORDER BY id'),
++ attachedEntities: db.prepare('SELECT DISTINCT entity_id FROM wiki_page_attachments ORDER BY entity_id'),
};
// ── Paths and small helpers ───────────────────────────────
@@@ -139,29 +156,23 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));
const fail = (status, code, message, extra) => { throw new WikiError(status, code, message, extra); };
-- // Work that must wait until the write has committed (never inside the transaction). Queued by
-- // afterCommit() and run once the outermost tx() returns; dropped when the transaction throws.
-- let commitQueue = null;
-- function tx(fn) {
-- if (commitQueue) return db.transaction(fn)();
-- commitQueue = [];
-- let out;
-- try {
-- out = db.transaction(fn)();
-- } catch (err) {
-- commitQueue = null;
-- throw err;
-- }
-- const queue = commitQueue;
-- commitQueue = null;
++ // Work that must wait until the write has committed (never inside the transaction). Queued by afterCommit()
++ // in the running request's own queue (AsyncLocalStorage: concurrent requests never share one), run once the
++ // outermost tx() has committed, dropped when it throws. A tx() inside a tx() is a savepoint.
++ const commits = new AsyncLocalStorage();
++ async function tx(fn) {
++ if (commits.getStore()) return db.tx(() => fn());
++ const queue = [];
++ const out = await commits.run(queue, () => db.tx(() => fn()));
for (const job of queue) {
-- try { job(); } catch (err) { log.warn(`[Wiki] after-commit: ${err && err.message}`); }
++ try { await job(); } catch (err) { log.warn(`[Wiki] after-commit: ${err && err.message}`); }
}
return out;
}
function afterCommit(job) {
-- if (commitQueue) commitQueue.push(job);
-- else job();
++ const queue = commits.getStore();
++ if (queue) queue.push(job);
++ else Promise.resolve().then(job).catch((err) => log.warn(`[Wiki] after-commit: ${err && err.message}`));
}
/**
@@@ -172,8 +183,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
*/
function followThread(pageId, visibility) {
if (!community || typeof community.setThreadVisibility !== 'function') return;
-- afterCommit(() => {
-- const known = discussions.get(pageId);
++ afterCommit(async () => {
++ const known = await discussions.get(pageId);
if (!known) return;
Promise.resolve()
.then(() => community.setThreadVisibility(known.threadId, visibility))
@@@ -193,14 +204,14 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return actor.subject;
}
-- function spaceOrFail(idOrSlug) {
-- const s = /^spc_/.test(String(idOrSlug)) ? q.spaceById.get(idOrSlug) : q.spaceBySlug.get(String(idOrSlug));
++ async function spaceOrFail(idOrSlug) {
++ const s = /^spc_/.test(String(idOrSlug)) ? await q.spaceById.get(idOrSlug) : await q.spaceBySlug.get(String(idOrSlug));
if (!s || s.deleted_at) fail(404, 'space.not_found', 'No such space');
return s;
}
-- function pageOrFail(id) {
-- const p = PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) : null;
++ async function pageOrFail(id) {
++ const p = PAGE_ID_RE.test(String(id)) ? await q.pageById.get(id) : null;
if (!p) fail(404, 'page.not_found', 'No such page');
return p;
}
@@@ -239,26 +250,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* A renamed (or deleted) space's old slug keeps answering with its redirect (or 410): nobody but
* that space may take it, or the old links would serve someone else's pages.
*/
-- function checkSlugNotRetired(slug, spaceId = null) {
-- const r = svc.resolveRedirect(`/s/${slug}`);
++ async function checkSlugNotRetired(slug, spaceId = null) {
++ const r = await svc.resolveRedirect(`/s/${slug}`);
if (r && r.entityId !== spaceId) fail(409, 'space.slug_retired', `The space slug "${slug}" belonged to another space and still leads there`);
}
-- function wrapContentError(fn) {
-- try { return fn(); } catch (err) {
-- if (err instanceof content.ContentError) fail(422, err.code, err.message);
-- if (err && err.name === 'PublishingError') fail(err.status || 400, err.code, err.message, err.expected !== undefined ? { expected: err.expected, current: err.current } : undefined);
-- if (err instanceof TypeError && /citation|url|quote|retrievedAt|media|role|variant|workflow|authors|mode/i.test(err.message)) fail(422, 'wiki.invalid_input', err.message);
-- throw err;
-- }
++ function contentError(err) {
++ if (err instanceof content.ContentError) fail(422, err.code, err.message);
++ if (err && err.name === 'PublishingError') fail(err.status || 400, err.code, err.message, err.expected !== undefined ? { expected: err.expected, current: err.current } : undefined);
++ if (err instanceof TypeError && /citation|url|quote|retrievedAt|media|role|variant|workflow|authors|mode/i.test(err.message)) fail(422, 'wiki.invalid_input', err.message);
++ throw err;
}
++ function wrapContentError(fn) { try { return fn(); } catch (err) { return contentError(err); } }
++ async function wrapStore(fn) { try { return await fn(); } catch (err) { return contentError(err); } }
// ── Events ───────────────────────────────────────────────
-- function emit(envelope) { return outbox.enqueue(envelope); }
++ function emit(envelope) { return outbox.enqueue(db, envelope); }
-- function spaceEvent(space, actor, change) {
-- q.dirtySpace.run({ id: space.id, t: now() });
-- emit({
++ async function spaceEvent(space, actor, change) {
++ await q.dirtySpace.run({ id: space.id, t: now() });
++ await emit({
event_type: 'wiki.space.updated',
actor: hooks.subjectRef(actorId(actor)),
subject: { type: 'space', id: space.id },
@@@ -273,7 +284,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
function revisionEvent(space, page, rev, actor, extra = {}) {
const rec = rev.meta && rev.meta.authorship;
// A new revision is a draft until it is published: the event is internal.
-- emit({
++ return emit({
event_type: 'wiki.revision.created',
actor: hooks.subjectRef(actorId(actor)),
subject: { type: 'page', id: page.id, revision: rev.number },
@@@ -287,12 +298,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
/** One event per change listing the watchers who may read the page (never the actor). */
-- function notifyWatchers(space, page, action, revision, actor) {
++ async function notifyWatchers(space, page, action, revision, actor) {
const who = actorId(actor);
-- const recipients = q.watchers.all(page.id).map((r) => r.subject)
-- .filter((s) => s !== who && access.subjectCanRead(space, page.state === 'deleted' ? { ...page, state: 'published' } : page, s));
++ const subjects = (await q.watchers.all(page.id)).map((r) => r.subject).filter((s) => s !== who);
++ if (!subjects.length) return;
++ const actorFor = await access.subjectActors(space);
++ const readable = page.state === 'deleted' ? { ...page, state: 'published' } : page;
++ const recipients = subjects.filter((s) => access.subjectCanRead(space, readable, s, actorFor));
if (!recipients.length) return;
-- emit({
++ await emit({
event_type: 'wiki.watch.triggered',
actor: hooks.subjectRef(who),
subject: { type: 'page', id: page.id, revision: revision || 0 },
@@@ -302,27 +316,29 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
// ── The gate, index documents and publication events ─────
-- function gateFacts(space, page, rev) {
-- const text = ssr.markdownToText(rev.content);
++ /** Gate facts from facts already read (no query of its own). */
++ function gateFactsWith(space, page, rev, citationCount, review) {
const rec = rev.meta && rev.meta.authorship;
return {
state: space.deleted_at ? 'deleted' : page.state,
visibility: GATE_VIS[access.effectiveVisibility(space, page)],
canonicalUrl: pageUrl(space, page),
-- text,
-- citationCount: citations.forRevision(page.id, rev.number).length,
++ text: ssr.markdownToText(rev.content),
++ citationCount,
noindex: !!page.noindex,
-- ...(rec ? authorshipGateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
++ ...(rec ? authorshipGateFacts(rec, review) : {}),
};
}
--
-- function decide(space, page, rev) {
-- return seo.evaluate(gateFacts(space, page, rev), { policy, now: now() });
++ async function gateFacts(space, page, rev) {
++ const [cites, review] = await Promise.all([citations.forRevision(page.id, rev.number), reviews.latest(page.id, rev.number)]);
++ return gateFactsWith(space, page, rev, cites.length, review);
}
++ const decideWith = (space, page, rev, cites, review) => seo.evaluate(gateFactsWith(space, page, rev, cites.length, review), { policy, now: now() });
++ async function decide(space, page, rev) { return seo.evaluate(await gateFacts(space, page, rev), { policy, now: now() }); }
-- function snapshot(page) {
++ async function snapshot(page) {
if (!page) return null;
-- const space = q.spaceById.get(page.space_id);
++ const space = await q.spaceById.get(page.space_id);
return { state: space.deleted_at ? 'deleted' : page.state, visibility: access.effectiveVisibility(space, page), revision: page.published_revision };
}
@@@ -332,15 +348,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* product event when the publication state moved. Both go to the outbox in the caller's transaction.
* When the page stops (or starts) being public, its discussion thread follows after the commit.
*/
-- function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
-- const page = q.pageById.get(pageId);
-- const space = q.spaceById.get(page.space_id);
++ async function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
++ const page = await q.pageById.get(pageId);
++ const space = await q.spaceById.get(page.space_id);
const eff = access.effectiveVisibility(space, page);
const state = space.deleted_at ? 'deleted' : page.state;
-- const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;
-- const decision = rev && state === 'published' ? decide(space, page, rev) : null;
-- const cites = rev ? citations.forRevision(page.id, rev.number) : [];
-- const doc = sequencer.stamp(hooks.buildIndexDocument({
++ const rev = page.published_revision ? await revisions.get(page.id, page.published_revision) : null;
++ const cites = rev ? await citations.forRevision(page.id, rev.number) : [];
++ const decision = rev && state === 'published' ? decideWith(space, page, rev, cites, await reviews.latest(page.id, rev.number)) : null;
++ const doc = await sequencer.stamp(db, hooks.buildIndexDocument({
owner: 'wiki', type: 'page', id: page.id, revision: 0, state, visibility: GATE_VIS[eff],
// Not listable (e.g. unreviewed generated text): never in Search, like members/private pages.
deleted: eff !== 'public' || !decision || !decision.listable,
@@@ -352,7 +368,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
publishedAt: page.published_at, updatedAt: page.revision_published_at,
}));
const sentBefore = before ? before.indexRevision : null;
-- if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
++ if (doc.revision !== sentBefore) await emit(hooks.indexEvent({ document: doc, now: now() }));
const after = { state, visibility: eff, revision: page.published_revision };
const wasPublic = Boolean(before) && before.state === 'published' && before.visibility === 'public';
@@@ -368,45 +384,47 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// its state, not a Search document.
evDoc = { owner: 'wiki', type: 'page', id: page.id, revision: doc.revision, deleted: false, visibility: GATE_VIS[eff], canonical_url: pageUrl(space, page), publication_state: 'published', indexability: decision ? hooks.searchIndexability(decision) : null };
}
-- emit(hooks.publicationEvent({
++ await emit(hooks.publicationEvent({
product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0,
actor: hooks.subjectRef(actorId(actor)), document: evDoc, decision, now: now(),
extra: { space: space.slug, slug: page.slug, visibility: eff },
}));
-- notifyWatchers(space, page, action, page.published_revision, actor);
++ await notifyWatchers(space, page, action, page.published_revision, actor);
}
return { action, indexRevision: doc.revision, deleted: doc.deleted };
}
-- function before(page) {
-- const s = snapshot(page);
-- return s ? { ...s, indexRevision: sequencer.current('wiki', 'page', page.id) } : null;
++ async function before(page) {
++ const s = await snapshot(page);
++ return s ? { ...s, indexRevision: await sequencer.current('wiki', 'page', page.id) } : null;
}
// ── Side tables written with each revision ───────────────
-- function writeRevisionExtras(space, page, rev, infobox) {
-- for (const l of content.extractLinks(rev.content, space.slug)) q.insertLink.run(page.id, rev.number, l.space, l.slug, l.label);
-- infobox.forEach((e, i) => q.insertInfobox.run(page.id, rev.number, i, e.key, e.label, e.type,
-- e.type === 'number' ? null : String(e.value), e.type === 'number' ? e.value : null));
++ async function writeRevisionExtras(space, page, rev, infobox) {
++ for (const l of content.extractLinks(rev.content, space.slug)) await q.insertLink.run(page.id, rev.number, l.space, l.slug, l.label);
++ for (const [i, e] of infobox.entries()) {
++ await q.insertInfobox.run(page.id, rev.number, i, e.key, e.label, e.type, e.type === 'number' ? null : String(e.value), e.type === 'number' ? e.value : null);
++ }
}
-- function infoboxOf(pageId, revision) {
-- return q.infoboxOf.all(pageId, revision).map((r) => ({
++ async function infoboxOf(pageId, revision) {
++ return (await q.infoboxOf.all(pageId, revision)).map((r) => ({
key: r.key, label: r.label, type: r.type,
value: r.type === 'number' ? r.value_number : r.type === 'boolean' ? r.value_text === 'true' : r.value_text,
}));
}
-- function attachCitationList(page, revisionNumber, list, actor) {
++ async function attachCitationList(page, revisionNumber, list, actor) {
if (!list || !list.length) return [];
-- return wrapContentError(() => citations.attachMany(page.id, revisionNumber, list.map((c) => {
++ const items = list.map((c) => {
if (!c || (!c.url && !c.sourceItemId)) fail(422, 'citation.no_source', 'A citation needs a URL or a Sources item id');
if (c.url && !c.sourceItemId && !c.retrievedAt) fail(422, 'citation.retrieved_at_required', `When was ${c.url} read? A URL citation needs retrievedAt`);
return { ...c, attachedBy: actorId(actor) };
-- })));
++ });
++ return wrapStore(() => citations.attachMany(page.id, revisionNumber, items));
}
-- function carryCitations(page, from, to, keep, actor) {
++ async function carryCitations(page, from, to, keep, actor) {
if (!from || keep === 'none' || (Array.isArray(keep) && !keep.length)) return [];
const ids = keep === 'all' || keep == null ? null : keep.map(Number).filter(Number.isInteger);
return citations.carryForward({ entityId: page.id, fromRevision: from, toRevision: to, ids, attachedBy: actorId(actor) });
@@@ -417,86 +435,101 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
/** A new page with its first revision (permission checks and authorship are the caller's). */
-- function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
++ async function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const vis = checkVisibility(visibility, 'public', space);
-- return tx(() => {
-- const existing = q.pageBySlug.get(space.id, slug);
++ return tx(async () => {
++ const existing = await q.pageBySlug.get(space.id, slug);
if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
-- if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }
++ if (parentId) { const par = await q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }
const t = now();
const id = pageIdNew(t);
-- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
-- redirects.release(`/w/${space.slug}/${slug}`);
-- const { revision } = wrapContentError(() => revisions.create({
++ await q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
++ await redirects.release(`/w/${space.slug}/${slug}`);
++ const { revision } = await wrapStore(() => revisions.create({
entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: cleanSummary, infobox: box },
meta: { authorship: rec }, author: actorId(actor), message,
}));
-- const page = q.pageById.get(id);
-- writeRevisionExtras(space, page, revision, box);
-- attachCitationList(page, revision.number, cites, actor);
-- revisionEvent(space, page, revision, actor);
-- if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);
++ const page = await q.pageById.get(id);
++ await writeRevisionExtras(space, page, revision, box);
++ await attachCitationList(page, revision.number, cites, actor);
++ await revisionEvent(space, page, revision, actor);
++ if (/^usr_/.test(actorId(actor))) await q.watch.run(id, actorId(actor), t);
return { page, revision };
});
}
// ── Publication (internal: no permission checks) ─────────
-- function publishRevision(page, revisionNumber, actor) {
-- const rev = revisions.get(page.id, revisionNumber);
++ async function publishRevision(page, revisionNumber, actor) {
++ const rev = await revisions.get(page.id, revisionNumber);
if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
const rec = rev.meta.authorship;
if (rec) {
-- const ok = authorship.canPublish(rec, reviews.latest(page.id, revisionNumber));
++ const ok = authorship.canPublish(rec, await reviews.latest(page.id, revisionNumber));
if (!ok.ok) fail(409, ok.reason, 'AI-generated revisions are published only after a person approves them');
}
-- const b = before(page);
-- const t = now();
-- db.prepare(`UPDATE wiki_pages SET state = 'published', published_revision = ?, title = ?, published_at = COALESCE(published_at, ?),
-- revision_published_at = CASE WHEN published_revision IS ? AND state = 'published' THEN revision_published_at ELSE ? END, updated_at = ? WHERE id = ?`)
-- .run(revisionNumber, rev.fields.title || page.title, t, revisionNumber, t, t, page.id);
++ const b = await before(page);
++ await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });
return sync(b, page.id, actor);
}
/**
* Readers who are not editors see the published revision and the history before it, never
-- * newer drafts and never an AI proposal a person has not approved.
++ * newer drafts and never an AI proposal a person has not approved. `proposal` is the one filed
++ * for revision n, when the caller already read it (undefined: read here).
*/
-- function revisionVisible(space, page, n, actor) {
++ async function revisionVisible(space, page, n, actor, proposal) {
if (access.canEdit(space, actor)) return true;
if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;
-- const p = q.proposalByRev.get(page.id, n);
++ const p = proposal === undefined ? await q.proposalByRev.get(page.id, n) : proposal;
return !p || p.status === 'approved';
}
++ /** { space, page, rev, decision } for published pages, in four queries whatever their number (no N+1). */
++ async function expandPublished(pages) {
++ if (!pages.length) return [];
++ const spaces = new Map((await q.spacesByIds.all([...new Set(pages.map((p) => p.space_id))])).map((s) => [s.id, s]));
++ const refs = pages.map((p) => ({ entityId: p.id, revision: p.published_revision }));
++ const [revs, cites, revws] = await Promise.all([revisions.getMany(refs), citations.forRevisions(refs), reviews.latestMany(refs)]);
++ const out = [];
++ pages.forEach((page, i) => {
++ const space = spaces.get(page.space_id);
++ if (!space || !revs[i]) return;
++ out.push({ space, page, rev: revs[i], decision: decideWith(space, page, revs[i], cites[i] || [], revws[i]) });
++ });
++ return out;
++ }
++
// ── Public API ───────────────────────────────────────────
const svc = {
-- access, spacePath, pagePath, pageUrl, WikiError,
++ access, spacePath, pagePath, pageUrl, WikiError, loadRoles: roles,
// Spaces ---------------------------------------------------------------------------
-- listSpaces(actor) { return q.spaces.all().filter((s) => access.canReadSpace(s, actor)); },
++ async listSpaces(actor) { await roles(actor); return (await q.spaces.all()).filter((s) => access.canReadSpace(s, actor)); },
/** VIP spaces (for preparing a viewer's VIP answers before a list renders; WS-K task 8). */
-- vipSpaces() { return q.spaces.all().filter((s) => s.visibility === 'vip').slice(0, 50); },
++ async vipSpaces() { return (await q.spaces.all()).filter((s) => s.visibility === 'vip').slice(0, 50); },
/** A space's VIP-only pages (its tree), at most 50. */
-- vipPagesOf(spaceId) { return db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND visibility = 'vip' AND state <> 'deleted' LIMIT 50").all(spaceId); },
++ vipPagesOf(spaceId) { return q.vipPages.all(spaceId); },
-- getSpace(idOrSlug, actor) {
-- const s = spaceOrFail(idOrSlug);
++ async getSpace(idOrSlug, actor) {
++ await roles(actor);
++ const s = await spaceOrFail(idOrSlug);
if (!access.canReadSpace(s, actor)) fail(404, 'space.not_found', 'No such space');
return s;
},
/** Raw lookup for redirects and routing (no permission check; callers decide). */
-- findSpace(slug) { return q.spaceBySlug.get(String(slug)) || null; },
-- findPage(spaceId, slug) { return q.pageBySlug.get(spaceId, String(slug)) || null; },
-- pageById(id) { return PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) || null : null; },
-- spaceById(id) { return q.spaceById.get(id) || null; },
++ async findSpace(slug) { return (await q.spaceBySlug.get(String(slug))) || null; },
++ async findPage(spaceId, slug) { return (await q.pageBySlug.get(spaceId, String(slug))) || null; },
++ async pageById(id) { return PAGE_ID_RE.test(String(id)) ? (await q.pageById.get(id)) || null : null; },
++ async spaceById(id) { return (await q.spaceById.get(id)) || null; },
-- createSpace({ slug, name, description = null, kind = 'user', visibility = 'public' } = {}, actor) {
++ async createSpace({ slug, name, description = null, kind = 'user', visibility = 'public' } = {}, actor) {
++ await roles(actor);
const cleanName = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 120);
if (!cleanName) fail(422, 'space.invalid_name', 'A space needs a name');
let cleanSlug;
@@@ -512,26 +545,30 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
if (kind === 'user') requirePerson(actor);
const owner = kind === 'user' ? actor.subject : (actor && actor.kind === 'service' ? actor.service : 'svc:wiki');
-- return tx(() => {
-- if (q.spaceBySlug.get(cleanSlug)) fail(409, 'space.slug_taken', `The space slug "${cleanSlug}" is taken`);
-- checkSlugNotRetired(cleanSlug);
++ const space = await tx(async () => {
++ if (await q.spaceBySlug.get(cleanSlug)) fail(409, 'space.slug_taken', `The space slug "${cleanSlug}" is taken`);
++ await checkSlugNotRetired(cleanSlug);
const t = now();
const id = spaceIdNew(t);
-- q.insertSpace.run({ id, slug: cleanSlug, name: cleanName, description: description == null ? null : String(description).slice(0, 1000), kind, visibility: vis, owner, created_by: actorId(actor), now: t });
-- if (/^usr_/.test(owner)) q.putRole.run(id, owner, 'owner', actorId(actor), t);
-- redirects.release(`/s/${cleanSlug}`);
-- const space = q.spaceById.get(id);
-- spaceEvent(space, actor, 'created');
-- return space;
++ await q.insertSpace.run({ id, slug: cleanSlug, name: cleanName, description: description == null ? null : String(description).slice(0, 1000), kind, visibility: vis, owner, created_by: actorId(actor), now: t });
++ if (/^usr_/.test(owner)) await q.putRole.run(id, owner, 'owner', actorId(actor), t);
++ await redirects.release(`/s/${cleanSlug}`);
++ const made = await q.spaceById.get(id);
++ await spaceEvent(made, actor, 'created');
++ return made;
});
++ await roles(actor, { fresh: true });
++ return space;
},
-- updateSpace(idOrSlug, { name, description, visibility, slug } = {}, actor) {
-- const space = spaceOrFail(idOrSlug);
++ async updateSpace(idOrSlug, { name, description, visibility, slug } = {}, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can change it');
-- return tx(() => {
-- const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));
-- const snaps = new Map(pages.map((p) => [p.id, before(p)]));
++ return tx(async () => {
++ const pages = await q.publishedOfSpace.all(space.id);
++ const snaps = new Map();
++ for (const p of pages) snaps.set(p.id, await before(p));
const t = now();
const next = {
name: name == null ? space.name : String(name).replace(/\s+/g, ' ').trim().slice(0, 120) || space.name,
@@@ -541,59 +578,72 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
};
if (!content.SPACE_SLUG_RE.test(next.slug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
if (next.slug !== space.slug) {
-- if (q.spaceBySlug.get(next.slug)) fail(409, 'space.slug_taken', `The space slug "${next.slug}" is taken`);
-- checkSlugNotRetired(next.slug, space.id);
-- redirects.recordMove(space.id, `/s/${space.slug}`, `/s/${next.slug}`, { reason: 'space_renamed' });
-- for (const p of q.pagesOfSpace.all(space.id)) redirects.recordMove(p.id, `/w/${space.slug}/${p.slug}`, `/w/${next.slug}/${p.slug}`, { reason: 'space_renamed' });
++ if (await q.spaceBySlug.get(next.slug)) fail(409, 'space.slug_taken', `The space slug "${next.slug}" is taken`);
++ await checkSlugNotRetired(next.slug, space.id);
++ await redirects.recordMove(space.id, `/s/${space.slug}`, `/s/${next.slug}`, { reason: 'space_renamed' });
++ for (const p of await q.pagesOfSpace.all(space.id)) await redirects.recordMove(p.id, `/w/${space.slug}/${p.slug}`, `/w/${next.slug}/${p.slug}`, { reason: 'space_renamed' });
}
-- db.prepare('UPDATE wiki_spaces SET name = ?, description = ?, visibility = ?, slug = ?, updated_at = ? WHERE id = ?')
++ await db.prepare('UPDATE wiki_spaces SET name = ?, description = ?, visibility = ?, slug = ?, updated_at = ? WHERE id = ?')
.run(next.name, next.description, next.visibility, next.slug, t, space.id);
-- const updated = q.spaceById.get(space.id);
-- spaceEvent(updated, actor, next.slug !== space.slug ? 'renamed' : (next.visibility !== space.visibility ? 'visibility' : 'settings'));
-- for (const p of pages) sync(snaps.get(p.id), p.id, actor);
++ const updated = await q.spaceById.get(space.id);
++ await spaceEvent(updated, actor, next.slug !== space.slug ? 'renamed' : (next.visibility !== space.visibility ? 'visibility' : 'settings'));
++ for (const p of pages) await sync(snaps.get(p.id), p.id, actor);
return updated;
});
},
-- deleteSpace(idOrSlug, actor) {
-- const space = spaceOrFail(idOrSlug);
++ async deleteSpace(idOrSlug, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can delete it');
-- return tx(() => {
-- const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));
-- const snaps = new Map(pages.map((p) => [p.id, before(p)]));
-- db.prepare('UPDATE wiki_spaces SET deleted_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), space.id);
-- for (const p of pages) { scheduler.cancelPending(p.id); sync(snaps.get(p.id), p.id, actor); }
-- spaceEvent(q.spaceById.get(space.id), actor, 'deleted');
++ return tx(async () => {
++ const pages = await q.publishedOfSpace.all(space.id);
++ const snaps = new Map();
++ for (const p of pages) snaps.set(p.id, await before(p));
++ await db.prepare('UPDATE wiki_spaces SET deleted_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), space.id);
++ for (const p of pages) { await scheduler.cancelPending(p.id); await sync(snaps.get(p.id), p.id, actor); }
++ await spaceEvent(await q.spaceById.get(space.id), actor, 'deleted');
return { deleted: true };
});
},
-- roles(idOrSlug, actor) {
-- const space = spaceOrFail(idOrSlug);
++ async roles(idOrSlug, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner sees the role list');
-- return q.roles.all(space.id).map((r) => ({ subject: r.subject, role: r.role, granted_by: r.granted_by, granted_at: toIso(r.granted_at) }));
++ return (await q.roles.all(space.id)).map((r) => ({ subject: r.subject, role: r.role, granted_by: r.granted_by, granted_at: toIso(r.granted_at) }));
},
-- setRole(idOrSlug, subject, role, actor) {
-- const space = spaceOrFail(idOrSlug);
++ async setRole(idOrSlug, subject, role, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner can change roles');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) fail(422, 'wiki.invalid_subject', 'Roles go to people: a usr_… subject id');
if (role != null && !['owner', 'editor', 'viewer'].includes(role)) fail(422, 'wiki.invalid_role', 'role is owner, editor or viewer (or null to remove)');
-- return tx(() => {
-- const current = q.roles.all(space.id).find((r) => r.subject === subject);
-- const losingOwner = current && current.role === 'owner' && role !== 'owner';
-- if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
-- if (role == null) q.delRole.run(space.id, subject);
-- else q.putRole.run(space.id, subject, role, actorId(actor), now());
-- q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list
-- spaceEvent(space, actor, 'roles');
-- return svc.roles(space.id, actor);
-- });
++ try {
++ return await tx(async () => {
++ const current = (await q.roles.all(space.id)).find((r) => r.subject === subject);
++ const losingOwner = current && current.role === 'owner' && role !== 'owner';
++ if (losingOwner && space.kind === 'user' && (await q.owners.get(space.id)).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
++ if (role == null) await q.delRole.run(space.id, subject);
++ else await q.putRole.run(space.id, subject, role, actorId(actor), now());
++ await q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list
++ await spaceEvent(space, actor, 'roles');
++ // As before: the list is read with the roles just written, so an owner who hands their own
++ // role away is refused (403) and the change rolls back.
++ await roles(actor, { fresh: true });
++ return svc.roles(space.id, actor);
++ });
++ } catch (err) {
++ await roles(actor, { fresh: true }).catch(() => {}); // the cached roles must not keep a rolled-back change
++ throw err;
++ }
},
// Pages ----------------------------------------------------------------------------
-- tree(space, actor) {
-- const pages = q.pagesOfSpace.all(space.id).filter((p) => access.canReadPage(space, p, actor));
++ async tree(space, actor) {
++ await roles(actor);
++ const pages = (await q.pagesOfSpace.all(space.id)).filter((p) => access.canReadPage(space, p, actor));
const byParent = new Map();
for (const p of pages) {
const k = p.parent_id && pages.some((x) => x.id === p.parent_id) ? p.parent_id : null;
@@@ -604,8 +654,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return build(null, 0);
},
-- createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {
-- const space = spaceOrFail(spaceIdOrSlug);
++ async createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(spaceIdOrSlug);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');
// Only this service's own import path (the seed) may state another authorship; people are human authors.
const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);
@@@ -619,8 +670,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* bundle (server/wiki/import.js). Only an owner of the space, acting as a person. A space the
* caller cannot read is "not found", exactly like everywhere else.
*/
-- prepareImport(spaceIdOrSlug, input, actor) {
-- const space = spaceOrFail(spaceIdOrSlug);
++ async prepareImport(spaceIdOrSlug, input, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(spaceIdOrSlug);
if (!access.canReadSpace(space, actor)) fail(404, 'space.not_found', 'No such space');
if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
requirePerson(actor);
@@@ -637,14 +689,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* stays noindex until a person reviews each page. Existing slugs: "fail" (409, nothing
* imported) or "skip" (left alone, reported).
*/
-- importPages(spaceIdOrSlug, bundle, actor) {
-- const space = spaceOrFail(spaceIdOrSlug);
++ async importPages(spaceIdOrSlug, bundle, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(spaceIdOrSlug);
if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
const who = requirePerson(actor);
const rec = wrapContentError(() => authorship.record({ mode: 'imported', authors: [who], importedFrom: { label: bundle.source || 'a page bundle', originalAuthor: bundle.originalAuthor || null } }));
if (bundle.aiAssisted) rec.importedFrom.aiAssisted = true;
-- return tx(() => {
-- const existing = bundle.pages.map((p) => ({ p, page: q.pageBySlug.get(space.id, p.slug) })).filter((x) => x.page);
++ return tx(async () => {
++ const existing = [];
++ for (const p of bundle.pages) { const page = await q.pageBySlug.get(space.id, p.slug); if (page) existing.push({ p, page }); }
if (existing.length && bundle.onExisting !== 'skip') {
fail(409, 'import.pages_exist', `Already in this space: ${existing.map((x) => x.p.slug).join(', ')}. Nothing was imported (import with on_existing "skip" to leave them alone)`, { slugs: existing.map((x) => x.p.slug) });
}
@@@ -661,13 +715,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
let parentId = null;
if (p.parentSlug) {
if (toCreate.has(p.parentSlug) && !made.has(p.parentSlug)) { next.push(p); continue; }
-- const parent = made.get(p.parentSlug) || q.pageBySlug.get(space.id, p.parentSlug);
++ const parent = made.get(p.parentSlug) || await q.pageBySlug.get(space.id, p.parentSlug);
if (!parent || parent.state === 'deleted') fail(422, 'import.invalid_parent', `"${p.title}": its parent is neither in the bundle nor a page of this space`);
parentId = parent.id;
}
let out;
try {
-- out = insertPage(space, { ...p, parentId }, actor, rec);
++ out = await insertPage(space, { ...p, parentId }, actor, rec);
} catch (err) {
if (err instanceof WikiError) fail(err.status, err.code, `"${p.title}": ${err.message}`, err.extra);
throw err;
@@@ -678,8 +732,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (next.length === pending.length) fail(422, 'import.invalid_parent', `Parents form a loop: ${next.map((p) => p.title).join(', ')}`);
pending = next;
}
-- if (bundle.publish) for (const c of created) publishRevision(q.pageById.get(c.page.id), c.revision.number, actor);
-- return { space, created: created.map((c) => ({ page: q.pageById.get(c.page.id), revision: c.revision })), skipped, published: !!bundle.publish };
++ if (bundle.publish) for (const c of created) await publishRevision(await q.pageById.get(c.page.id), c.revision.number, actor);
++ const pagesNow = [];
++ for (const c of created) pagesNow.push({ page: await q.pageById.get(c.page.id), revision: c.revision });
++ return { space, created: pagesNow, skipped, published: !!bundle.publish };
});
},
@@@ -688,109 +744,115 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* forward, an array keeps only those citation ids, 'none' drops them all (they stay on
* the older revision). citations: new ones for this revision.
*/
-- editPage(pageId, { expectedRevision, baseRevision = null, title, body, infobox, summary, citations: cites = [], keepCitations = 'all', message = null } = {}, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async editPage(pageId, { expectedRevision, baseRevision = null, title, body, infobox, summary, citations: cites = [], keepCitations = 'all', message = null } = {}, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can edit it');
const rec = humanRecord(actor);
if (!Number.isInteger(expectedRevision)) fail(422, 'revision.expected_required', 'expectedRevision (the revision you edited) is required');
-- return tx(() => {
-- const head = revisions.head(page.id);
++ return tx(async () => {
++ const head = await revisions.head(page.id);
if (!head) fail(404, 'revision.not_found', 'This page has no revision');
const text = body == null ? head.content : checkBody(body);
const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));
const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : checkSummary(summary), infobox: box };
-- const { revision, created } = wrapContentError(() => revisions.create({
++ const { revision, created } = await wrapStore(() => revisions.create({
entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,
}));
if (!created) return { page, revision, created: false };
-- writeRevisionExtras(space, page, revision, box);
++ await writeRevisionExtras(space, page, revision, box);
// Citations come from the revision the editor started from (the head unless the
// head is an AI proposal the editor chose not to build on).
-- const from = baseRevision != null && revisions.get(page.id, Number(baseRevision)) ? Number(baseRevision) : head.number;
-- carryCitations(page, from, revision.number, keepCitations, actor);
-- attachCitationList(page, revision.number, cites, actor);
-- db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);
-- revisionEvent(space, page, revision, actor);
-- notifyWatchers(space, page, 'revised', revision.number, actor);
-- return { page: q.pageById.get(page.id), revision, created: true };
++ const from = baseRevision != null && await revisions.get(page.id, Number(baseRevision)) ? Number(baseRevision) : head.number;
++ await carryCitations(page, from, revision.number, keepCitations, actor);
++ await attachCitationList(page, revision.number, cites, actor);
++ await q.touch.run(now(), page.id);
++ await revisionEvent(space, page, revision, actor);
++ await notifyWatchers(space, page, 'revised', revision.number, actor);
++ return { page: await q.pageById.get(page.id), revision, created: true };
});
},
/** Revert = a new revision copying an old one (content, fields, citations, infobox). */
-- revert(pageId, { toRevision, expectedRevision, message = null, publish = null } = {}, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async revert(pageId, { toRevision, expectedRevision, message = null, publish = null } = {}, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can revert');
const who = requirePerson(actor);
-- return tx(() => {
-- const target = revisions.get(page.id, Number(toRevision));
++ return tx(async () => {
++ const target = await revisions.get(page.id, Number(toRevision));
if (!target) fail(404, 'revision.not_found', `No revision ${toRevision}`);
const trec = target.meta.authorship;
// Restoring AI text keeps the AI workflow on record: the reverting person is accountable (hybrid).
const rec = trec && (trec.mode === 'ai' || trec.mode === 'hybrid') && trec.workflow
? authorship.record({ mode: 'hybrid', authors: [who], workflow: trec.workflow })
: authorship.record({ mode: 'human', authors: [who] });
-- const { revision } = wrapContentError(() => revisions.revert({ entityId: page.id, toRevision: target.number, expectedRevision, author: who, message, meta: { authorship: rec } }));
-- writeRevisionExtras(space, page, revision, target.fields.infobox || []);
-- carryCitations(page, target.number, revision.number, 'all', actor);
-- db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);
-- revisionEvent(space, page, revision, actor, { reverted_to: target.number });
-- const livePage = q.pageById.get(page.id);
++ const { revision } = await wrapStore(() => revisions.revert({ entityId: page.id, toRevision: target.number, expectedRevision, author: who, message, meta: { authorship: rec } }));
++ await writeRevisionExtras(space, page, revision, target.fields.infobox || []);
++ await carryCitations(page, target.number, revision.number, 'all', actor);
++ await q.touch.run(now(), page.id);
++ await revisionEvent(space, page, revision, actor, { reverted_to: target.number });
++ const livePage = await q.pageById.get(page.id);
const shouldPublish = publish == null ? livePage.state === 'published' : !!publish;
-- if (shouldPublish) publishRevision(livePage, revision.number, actor);
-- else notifyWatchers(space, livePage, 'revised', revision.number, actor);
-- return { page: q.pageById.get(page.id), revision, published: shouldPublish };
++ if (shouldPublish) await publishRevision(livePage, revision.number, actor);
++ else await notifyWatchers(space, livePage, 'revised', revision.number, actor);
++ return { page: await q.pageById.get(page.id), revision, published: shouldPublish };
});
},
-- publish(pageId, { revision } = {}, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async publish(pageId, { revision } = {}, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can publish');
if (!actor || actor.kind !== 'system') requirePerson(actor);
-- return tx(() => {
-- const n = revision == null ? revisions.headNumber(page.id) : Number(revision);
-- const out = publishRevision(q.pageById.get(page.id), n, actor);
-- return { page: q.pageById.get(page.id), ...out };
++ return tx(async () => {
++ const n = revision == null ? await revisions.headNumber(page.id) : Number(revision);
++ const out = await publishRevision(await q.pageById.get(page.id), n, actor);
++ return { page: await q.pageById.get(page.id), ...out };
});
},
-- unpublish(pageId, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async unpublish(pageId, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can unpublish');
if (page.state !== 'published') fail(409, 'page.not_published', 'The page is not published');
-- return tx(() => {
-- const b = before(page);
-- db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);
-- scheduler.cancelPending(page.id, 'unpublish');
-- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
++ return tx(async () => {
++ const b = await before(page);
++ await q.setState.run('unpublished', now(), page.id);
++ await scheduler.cancelPending(page.id, 'unpublish');
++ const out = await sync(b, page.id, actor);
++ return { page: await q.pageById.get(page.id), ...out };
});
},
/** Idempotent: the same (page, revision, time) returns the existing job. */
-- schedulePublish(pageId, { revision, runAt } = {}, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async schedulePublish(pageId, { revision, runAt } = {}, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can schedule');
requirePerson(actor);
const at = typeof runAt === 'number' ? runAt : Date.parse(runAt);
if (!Number.isFinite(at)) fail(422, 'schedule.invalid_time', 'runAt must be an ISO 8601 time');
-- const n = revision == null ? revisions.headNumber(page.id) : Number(revision);
-- if (!revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${n}`);
-- return tx(() => {
-- const { job, created } = scheduler.schedule({ entityId: page.id, action: 'publish', runAt: at, revision: n });
++ const n = revision == null ? await revisions.headNumber(page.id) : Number(revision);
++ if (!await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${n}`);
++ return tx(async () => {
++ const { job, created } = await scheduler.schedule({ entityId: page.id, action: 'publish', runAt: at, revision: n });
if (page.state === 'draft' || page.state === 'unpublished') {
-- const b = before(page);
-- db.prepare("UPDATE wiki_pages SET state = 'scheduled', updated_at = ? WHERE id = ?").run(now(), page.id);
-- sync(b, page.id, actor);
++ const b = await before(page);
++ await q.setState.run('scheduled', now(), page.id);
++ await sync(b, page.id, actor);
}
-- return { job, created, page: q.pageById.get(page.id) };
++ return { job, created, page: await q.pageById.get(page.id) };
});
},
@@@ -800,130 +862,159 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
async runSchedule({ worker = config.workerId } = {}) {
return scheduler.runDue({
worker,
-- handler: (job) => tx(() => {
-- const page = q.pageById.get(job.entityId);
++ handler: (job) => tx(async () => {
++ const page = await q.pageById.get(job.entityId);
if (!page || page.state === 'deleted') return { skipped: 'deleted' };
-- const space = q.spaceById.get(page.space_id);
++ const space = await q.spaceById.get(page.space_id);
if (!space || space.deleted_at) return { skipped: 'space_deleted' };
if (job.action === 'publish') {
if (page.state === 'published' && page.published_revision === job.revision) return { noop: true };
-- publishRevision(page, job.revision, { kind: 'system', service: 'svc:wiki', subject: null });
++ await publishRevision(page, job.revision, { kind: 'system', service: 'svc:wiki', subject: null });
return { published: job.revision };
}
if (page.state !== 'published') return { noop: true };
-- const b = before(page);
-- db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);
-- sync(b, page.id, { kind: 'system', service: 'svc:wiki' });
++ const b = await before(page);
++ await q.setState.run('unpublished', now(), page.id);
++ await sync(b, page.id, { kind: 'system', service: 'svc:wiki' });
return { unpublished: true };
}),
});
},
-- movePage(pageId, { slug, title, parentId } = {}, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async movePage(pageId, { slug, title, parentId } = {}, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can move pages');
-- return tx(() => {
-- const b = before(page);
++ return tx(async () => {
++ const b = await before(page);
let nextSlug = page.slug;
if (slug != null || title != null) nextSlug = slug != null ? String(slug).trim().toLowerCase() : wrapContentError(() => content.pageSlug(checkTitle(title)));
if (!content.PAGE_SLUG_RE.test(nextSlug)) fail(422, 'page.invalid_slug', 'A page slug is lowercase letters, digits and dashes');
if (nextSlug !== page.slug) {
-- const clash = q.pageBySlug.get(space.id, nextSlug);
++ const clash = await q.pageBySlug.get(space.id, nextSlug);
if (clash) fail(409, 'page.slug_taken', `A page "${nextSlug}" already exists in this space`);
-- redirects.recordMove(page.id, `/w/${space.slug}/${page.slug}`, `/w/${space.slug}/${nextSlug}`);
++ await redirects.recordMove(page.id, `/w/${space.slug}/${page.slug}`, `/w/${space.slug}/${nextSlug}`);
}
let parent = page.parent_id;
if (parentId !== undefined) {
parent = parentId || null;
-- for (let p = parent ? q.pageById.get(parent) : null, i = 0; p; p = p.parent_id ? q.pageById.get(p.parent_id) : null, i++) {
++ for (let p = parent ? await q.pageById.get(parent) : null, i = 0; p; p = p.parent_id ? await q.pageById.get(p.parent_id) : null, i++) {
if (p.id === page.id || i > 50) fail(422, 'page.invalid_parent', 'A page cannot be moved under itself');
if (p.space_id !== space.id) fail(422, 'page.invalid_parent', 'The parent must be a page of the same space');
}
}
-- db.prepare('UPDATE wiki_pages SET slug = ?, parent_id = ?, updated_at = ? WHERE id = ?').run(nextSlug, parent, now(), page.id);
-- sync(b, page.id, actor);
++ await db.prepare('UPDATE wiki_pages SET slug = ?, parent_id = ?, updated_at = ? WHERE id = ?').run(nextSlug, parent, now(), page.id);
++ await sync(b, page.id, actor);
return q.pageById.get(page.id);
});
},
-- setPageVisibility(pageId, { visibility, noindex } = {}, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async setPageVisibility(pageId, { visibility, noindex } = {}, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');
-- return tx(() => {
-- const b = before(page);
-- db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
++ return tx(async () => {
++ const b = await before(page);
++ await db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
.run(checkVisibility(visibility, page.visibility, space), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
-- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
++ const out = await sync(b, page.id, actor);
++ return { page: await q.pageById.get(page.id), ...out };
});
},
-- deletePage(pageId, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async deletePage(pageId, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space can delete pages');
if (page.state === 'deleted') return { page, action: null };
-- return tx(() => {
-- const b = before(page);
-- db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), page.id);
-- db.prepare('UPDATE wiki_pages SET parent_id = ? WHERE parent_id = ?').run(page.parent_id, page.id);
-- scheduler.cancelPending(page.id);
-- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
++ return tx(async () => {
++ const b = await before(page);
++ await q.setState.run('deleted', now(), page.id);
++ await db.prepare('UPDATE wiki_pages SET parent_id = ? WHERE parent_id = ?').run(page.parent_id, page.id);
++ await scheduler.cancelPending(page.id);
++ const out = await sync(b, page.id, actor);
++ return { page: await q.pageById.get(page.id), ...out };
});
},
// Reading --------------------------------------------------------------------------
/** The page as `actor` may see it; revision defaults to the published one (editors: head of a draft). */
-- view(space, page, actor, { revision } = {}) {
++ async view(space, page, actor, { revision } = {}) {
++ await roles(actor);
if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
const canEdit = access.canEdit(space, actor);
-- let n = revision == null ? (page.state === 'published' ? page.published_revision : revisions.headNumber(page.id)) : Number(revision);
-- if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
-- const rev = revisions.get(page.id, n);
++ let n = revision == null ? (page.state === 'published' ? page.published_revision : await revisions.headNumber(page.id)) : Number(revision);
++ const proposal = Number.isInteger(n) ? await q.proposalByRev.get(page.id, n) : undefined;
++ if (!Number.isInteger(n) || !await revisionVisible(space, page, n, actor, proposal || null)) fail(404, 'revision.not_found', 'No such revision');
++ const rev = await revisions.get(page.id, n);
if (!rev) fail(404, 'revision.not_found', 'No such revision');
n = rev.number;
-- const review = reviews.latest(page.id, n);
++ const [review, cites, infobox, links, atts, children, parentRow, backlinks, discussion, watching, proposals, jobs, headNumber] = await Promise.all([
++ reviews.latest(page.id, n),
++ citations.forRevision(page.id, n),
++ infoboxOf(page.id, n),
++ q.linksOf.all(page.id, n),
++ svc.attachmentsOf(page.id),
++ q.children.all(page.id),
++ page.parent_id ? q.pageById.get(page.parent_id) : null,
++ svc.backlinks(space, page, actor),
++ discussions.get(page.id),
++ actor && actor.subject ? q.isWatching.get(page.id, actor.subject) : null,
++ canEdit ? q.proposalsOfPage.all(page.id) : [],
++ canEdit ? scheduler.jobs(page.id) : [],
++ canEdit ? revisions.headNumber(page.id) : null,
++ ]);
const rec = rev.meta.authorship || null;
-- const decision = page.state === 'published' && n === page.published_revision ? decide(space, page, rev) : seo.evaluate({ ...gateFacts(space, page, rev), state: page.state === 'published' ? 'draft' : page.state }, { policy, now: now() });
++ const facts = gateFactsWith(space, page, rev, cites.length, review);
++ const decision = page.state === 'published' && n === page.published_revision
++ ? seo.evaluate(facts, { policy, now: now() })
++ : seo.evaluate({ ...facts, state: page.state === 'published' ? 'draft' : page.state }, { policy, now: now() });
return {
space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),
isPublishedRevision: n === page.published_revision && page.state === 'published',
authorship: rec, review, disclosure: rec ? disclosureFor(rec, review) : null,
needsReview: !!rec && aiAssistedImport(rec) && !authorship.isReviewed(rec, review),
-- proposal: q.proposalByRev.get(page.id, n) || null,
-- infobox: infoboxOf(page.id, n),
-- citations: citations.forRevision(page.id, n),
-- links: q.linksOf.all(page.id, n),
-- attachments: svc.attachmentsOf(page.id),
-- children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),
-- parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,
-- backlinks: svc.backlinks(space, page, actor),
-- discussion: discussions.get(page.id),
-- watching: actor && actor.subject ? !!q.isWatching.get(page.id, actor.subject) : false,
-- pendingProposals: canEdit ? q.proposalsOfPage.all(page.id).filter((p) => p.status === 'pending') : [],
-- jobs: canEdit ? scheduler.jobs(page.id).filter((j) => j.status === 'pending' || j.status === 'running') : [],
-- headNumber: canEdit ? revisions.headNumber(page.id) : null,
++ proposal: proposal || null,
++ infobox,
++ citations: cites,
++ links,
++ attachments: atts,
++ children: children.filter((c) => access.canReadPage(space, c, actor)),
++ parent: parentRow && access.canReadPage(space, parentRow, actor) ? parentRow : null,
++ backlinks,
++ discussion,
++ watching: !!watching,
++ pendingProposals: proposals.filter((p) => p.status === 'pending'),
++ jobs: jobs.filter((j) => j.status === 'pending' || j.status === 'running'),
++ headNumber,
};
},
-- backlinks(space, page, actor) {
-- return q.backlinks.all(space.slug, page.slug).filter((p) => p.id !== page.id).map((p) => ({ page: p, space: q.spaceById.get(p.space_id) }))
-- .filter(({ page: p, space: s }) => access.canReadPage(s, p, actor));
++ async backlinks(space, page, actor) {
++ await roles(actor);
++ const rows = (await q.backlinks.all(space.slug, page.slug)).filter((p) => p.id !== page.id);
++ if (!rows.length) return [];
++ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));
++ return rows.map((p) => ({ page: p, space: spaces.get(p.space_id) })).filter(({ page: p, space: s }) => access.canReadPage(s, p, actor));
},
/** [[link]] target → { href, exists }. Pages the actor cannot read look missing. */
-- resolveLink({ space: spaceSlug, slug, title }, actor) {
-- const space = q.spaceBySlug.get(spaceSlug);
++ async resolveLink({ space: spaceSlug, slug, title }, actor) {
++ await roles(actor);
++ const space = await q.spaceBySlug.get(spaceSlug);
if (!space || space.deleted_at || !access.canReadSpace(space, actor)) return { href: null, exists: false };
-- let page = q.pageBySlug.get(space.id, slug);
++ let page = await q.pageBySlug.get(space.id, slug);
if (!page) {
-- const r = redirects.resolve(`/w/${space.slug}/${slug}`, { currentPath: (id) => { const p = q.pageById.get(id); return p && p.state !== 'deleted' ? pagePath(q.spaceById.get(p.space_id), p) : null; } });
-- if (r && r.status === 301) { const moved = q.pageById.get(r.entityId); if (moved) page = moved; }
++ const r = await redirects.resolve(`/w/${space.slug}/${slug}`, { currentPath: async (id) => { const p = await q.pageById.get(id); return p && p.state !== 'deleted' ? pagePath(await q.spaceById.get(p.space_id), p) : null; } });
++ if (r && r.status === 301) { const moved = await q.pageById.get(r.entityId); if (moved) page = moved; }
}
-- if (page && page.state === 'published' && access.canReadPage(q.spaceById.get(page.space_id), page, actor)) {
-- return { href: pagePath(q.spaceById.get(page.space_id), page), exists: true };
++ const pageSpace = page ? (page.space_id === space.id ? space : await q.spaceById.get(page.space_id)) : null;
++ if (page && page.state === 'published' && access.canReadPage(pageSpace, page, actor)) {
++ return { href: pagePath(pageSpace, page), exists: true };
}
if (!page && access.canEdit(space, actor)) return { href: `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(slug)}/edit?title=${encodeURIComponent(title || slug)}`, exists: false };
return { href: null, exists: false };
@@@ -932,84 +1023,82 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
/**
* Markdown → safe HTML. Outbound links in community (user) spaces carry rel="nofollow ugc":
* nobody vouches for them, so they pass no ranking (link spam gains nothing). Official spaces
-- * are edited by staff, who vouch for their links.
++ * are edited by staff, who vouch for their links. Every [[link]] is resolved first (the
++ * renderer itself is synchronous).
*/
-- renderRevision(space, rev, actor) {
-- return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor), rel: svc.linkRel(space) });
++ async renderRevision(space, rev, actor) {
++ const targets = content.extractLinks(rev.content, space.slug);
++ const resolved = new Map();
++ for (const t of targets) resolved.set(`${t.space}\u0000${t.slug}`, await svc.resolveLink(t, actor));
++ return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => resolved.get(`${t.space}\u0000${t.slug}`) || { href: null, exists: false }, rel: svc.linkRel(space) });
},
linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },
-- history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
-- const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));
-- const space = q.spaceById.get(page.space_id);
-- return revisions.list(page.id, { limit, before: cursor }).filter((r) => revisionVisible(space, page, r.number, actor)).map((r) => ({
-- ...r, proposal: props.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
-- review: reviews.latest(page.id, r.number),
++ async history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
++ await roles(actor);
++ const [props, space, list] = await Promise.all([q.proposalsOfPage.all(page.id), q.spaceById.get(page.space_id), revisions.list(page.id, { limit, before: cursor })]);
++ const byRev = new Map(props.map((p) => [p.revision, p]));
++ const visible = [];
++ for (const r of list) if (await revisionVisible(space, page, r.number, actor, byRev.get(r.number) || null)) visible.push(r);
++ const refs = visible.map((r) => ({ entityId: page.id, revision: r.number }));
++ const [revws, cites] = await Promise.all([reviews.latestMany(refs), citations.forRevisions(refs)]);
++ return visible.map((r, i) => ({
++ ...r, proposal: byRev.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
++ review: revws[i],
aiAssisted: aiAssistedImport(r.meta && r.meta.authorship),
-- needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, reviews.latest(page.id, r.number)),
-- citationCount: citations.forRevision(page.id, r.number).length,
++ needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, revws[i]),
++ citationCount: (cites[i] || []).length,
}));
},
-- diff(page, a, b, { mode = 'word', actor = { kind: 'system' } } = {}) {
-- const space = q.spaceById.get(page.space_id);
-- for (const n of [Number(a), Number(b)]) if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
-- return wrapContentError(() => revisions.diff(page.id, Number(a), Number(b), { mode: mode === 'line' ? 'line' : 'word' }));
++ async diff(page, a, b, { mode = 'word', actor = { kind: 'system' } } = {}) {
++ await roles(actor);
++ const space = await q.spaceById.get(page.space_id);
++ for (const n of [Number(a), Number(b)]) if (!Number.isInteger(n) || !await revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
++ return wrapStore(() => revisions.diff(page.id, Number(a), Number(b), { mode: mode === 'line' ? 'line' : 'word' }));
},
resolveRedirect(path) {
return redirects.resolve(path, {
-- currentPath: (id) => {
-- if (/^spc_/.test(id)) { const s = q.spaceById.get(id); return s && !s.deleted_at ? spacePath(s) : null; }
-- const p = q.pageById.get(id);
++ currentPath: async (id) => {
++ if (/^spc_/.test(id)) { const s = await q.spaceById.get(id); return s && !s.deleted_at ? spacePath(s) : null; }
++ const p = await q.pageById.get(id);
if (!p || p.state === 'deleted') return null;
-- const s = q.spaceById.get(p.space_id);
++ const s = await q.spaceById.get(p.space_id);
return s && !s.deleted_at ? pagePath(s, p) : null;
},
});
},
-- search(text, actor, { space = null, limit = 20 } = {}) {
++ async search(text, actor, { space = null, limit = 20 } = {}) {
++ await roles(actor);
const words = String(text || '').trim().slice(0, 200);
if (!words) return [];
const like = `%${words.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
-- const out = [];
-- for (const p of q.search.all({ like, space })) {
-- const s = q.spaceById.get(p.space_id);
-- if (!access.canReadPage(s, p, actor)) continue;
-- const rev = revisions.get(p.id, p.published_revision);
-- out.push({ page: p, space: s, summary: rev.fields.summary || ssr.markdownToText(rev.content, 200) });
-- if (out.length >= Math.min(50, Math.max(1, Number(limit) || 20))) break;
-- }
-- return out;
++ const rows = await q.search.all({ like, space: space || null });
++ if (!rows.length) return [];
++ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));
++ const max = Math.min(50, Math.max(1, Number(limit) || 20));
++ const hits = rows.filter((p) => access.canReadPage(spaces.get(p.space_id), p, actor)).slice(0, max);
++ const revs = await revisions.getMany(hits.map((p) => ({ entityId: p.id, revision: p.published_revision })));
++ return hits.map((p, i) => ({ page: p, space: spaces.get(p.space_id), summary: revs[i] ? (revs[i].fields.summary || ssr.markdownToText(revs[i].content, 200)) : '' }));
},
// Discovery (public, indexable only) -----------------------------------------------
-- publishedPublic() {
-- return q.allPublishedPublic.all().map((page) => {
-- const space = q.spaceById.get(page.space_id);
-- const rev = revisions.get(page.id, page.published_revision);
-- return { space, page, rev, decision: decide(space, page, rev) };
-- });
-- },
++ async publishedPublic() { return expandPublished(await q.allPublishedPublic.all()); },
-- recentChanges(limit = 50) {
-- return q.recent.all(Math.min(200, Math.max(1, limit))).map((page) => {
-- const space = q.spaceById.get(page.space_id);
-- const rev = revisions.get(page.id, page.published_revision);
-- return { space, page, rev, decision: decide(space, page, rev) };
-- });
-- },
++ async recentChanges(limit = 50) { return expandPublished(await q.recent.all(Math.min(200, Math.max(1, limit)))); },
// Citations and media ----------------------------------------------------------------
/** Citations go on an unpublished head revision (a published revision's sources are fixed). */
-- attachCitations(pageId, revisionNumber, list, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async attachCitations(pageId, revisionNumber, list, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can cite');
const n = Number(revisionNumber);
-- if (n !== revisions.headNumber(page.id)) fail(409, 'citation.not_head', 'Citations are attached to the newest revision; edit the page to cite in a new one');
++ if (n !== await revisions.headNumber(page.id)) fail(409, 'citation.not_head', 'Citations are attached to the newest revision; edit the page to cite in a new one');
if (page.state === 'published' && page.published_revision === n) fail(409, 'citation.revision_published', 'That revision is published; its sources are fixed. Edit the page to add sources to a new revision');
return tx(() => attachCitationList(page, n, list, actor));
},
@@@ -1023,21 +1112,22 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* read rules as the article (view() refuses what the actor may not see); an origin revision
* the actor may not read is not named.
*/
-- citationInspector(space, page, actor, { revision } = {}) {
-- const v = svc.view(space, page, actor, { revision });
++ async citationInspector(space, page, actor, { revision } = {}) {
++ const v = await svc.view(space, page, actor, { revision });
const n = v.revision.number;
-- const readable = svc.history(page, { limit: 500, actor });
++ const readable = await svc.history(page, { limit: 500, actor });
const visible = new Set(readable.map((r) => r.number));
const lineage = (c) => c.carriedFrom || c.id;
const prev = readable.find((r) => r.number < n) || null;
-- const prevCites = prev ? citations.forRevision(page.id, prev.number) : [];
++ const prevCites = prev ? await citations.forRevision(page.id, prev.number) : [];
const prevLines = new Set(prevCites.map(lineage));
-- const items = v.citations.map((c) => {
-- const first = c.carriedFrom ? citations.get(c.carriedFrom) : c;
++ const items = [];
++ for (const c of v.citations) {
++ const first = c.carriedFrom ? await citations.get(c.carriedFrom) : c;
const shown = first && visible.has(first.revision) ? first : null;
const change = prevLines.has(lineage(c)) ? 'kept' : (c.carriedFrom ? 'restored' : 'new');
-- return { ...c, change, firstRevision: shown ? shown.revision : null, firstAttachedAt: shown ? shown.attachedAt : null };
-- });
++ items.push({ ...c, change, firstRevision: shown ? shown.revision : null, firstAttachedAt: shown ? shown.attachedAt : null });
++ }
const here = new Set(v.citations.map(lineage));
return {
view: v, citations: items,
@@@ -1075,8 +1165,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* nothing is attached unchecked.
*/
async attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor, { describe } = {}) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');
const who = actor && actor.kind === 'system' ? null : requirePerson(actor);
@@@ -1092,26 +1183,28 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!readable) fail(422, 'media.not_readable', 'No OpenVibe.Media object you can read has that id');
if (obj.visibility === 'private') fail(409, 'media.private', 'That object is private in OpenVibe.Media. Every reader of a wiki page sees its media, so make the object unlisted or public in Media first');
if (obj.status && obj.status !== 'ready') fail(409, 'media.not_ready', `That object is ${obj.status} in OpenVibe.Media, not ready yet`);
-- return tx(() => {
-- const att = wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));
-- q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());
-- attachments.markAvailable(id);
-- return svc.attachmentsOf(page.id).find((a) => a.id === att.id);
++ return tx(async () => {
++ const att = await wrapStore(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));
++ await q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());
++ await attachments.markAvailable(id);
++ return (await svc.attachmentsOf(page.id)).find((a) => a.id === att.id);
});
},
/** A page's attachments, each with who attached it (null for rows older than the record). */
-- attachmentsOf(pageId) {
-- const origins = new Map(q.originsOf.all(pageId).map((o) => [o.attachment_id, o]));
-- return attachments.list(pageId).map((a) => {
-- const o = origins.get(a.id);
++ async attachmentsOf(pageId) {
++ const [origins, list] = await Promise.all([q.originsOf.all(pageId), attachments.list(pageId)]);
++ const byId = new Map(origins.map((o) => [o.attachment_id, o]));
++ return list.map((a) => {
++ const o = byId.get(a.id);
return { ...a, attachedBy: o ? o.attached_by : null, mediaVisibilityAtAttach: o ? o.media_visibility : null };
});
},
-- detachMedia(pageId, attachmentId, actor) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async detachMedia(pageId, attachmentId, actor) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can detach media');
return attachments.detach(page.id, attachmentId);
},
@@@ -1122,7 +1215,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
async verifyMedia(pageId, resolve) { return attachments.verify(pageId, { resolve }); },
async verifyAllMedia(resolve) {
-- const ids = db.prepare('SELECT DISTINCT entity_id FROM wiki_page_attachments').all().map((r) => r.entity_id);
++ const ids = (await q.attachedEntities.all()).map((r) => r.entity_id);
const out = [];
for (const id of ids) out.push(...await attachments.verify(id, { resolve }));
return out;
@@@ -1131,7 +1224,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// Discussion -------------------------------------------------------------------------
/** The Community thread of a public, published page (resolved once, then stored as a reference). */
async discussionThread(space, page, client) {
-- const known = discussions.get(page.id);
++ const known = await discussions.get(page.id);
if (known) return known.threadId;
if (page.state !== 'published' || access.effectiveVisibility(space, page) !== 'public') return null;
const out = await discussions.threadFor(page.id, { service: 'wiki', type: 'page', id: page.id, label: page.title.slice(0, 200) }, { client });
@@@ -1139,12 +1232,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
},
// Watchers ---------------------------------------------------------------------------
-- watch(pageId, actor, on = true) {
-- const page = pageOrFail(pageId);
-- const space = spaceOrFail(page.space_id);
++ async watch(pageId, actor, on = true) {
++ await roles(actor);
++ const page = await pageOrFail(pageId);
++ const space = await spaceOrFail(page.space_id);
const who = requirePerson(actor);
if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
-- if (on) q.watch.run(page.id, who, now()); else q.unwatch.run(page.id, who);
++ if (on) await q.watch.run(page.id, who, now()); else await q.unwatch.run(page.id, who);
return { watching: on };
},
@@@ -1154,12 +1248,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* AI-assisted imports becoming noindex until reviewed — reaches Search for rows written earlier.
*/
reconcileIndex() {
-- return tx(() => {
++ return tx(async () => {
let sent = 0;
-- for (const { id } of db.prepare('SELECT id FROM wiki_pages ORDER BY id').all()) {
-- const b = before(q.pageById.get(id));
++ for (const { id } of await q.pageIds.all()) {
++ const b = await before(await q.pageById.get(id));
if (b.indexRevision == null) continue; // never sent: nothing in Search to correct
-- const out = sync(b, id, { kind: 'system', service: 'svc:wiki' });
++ const out = await sync(b, id, { kind: 'system', service: 'svc:wiki' });
if (out.indexRevision !== b.indexRevision) sent++;
}
return { sent };
@@@ -1172,26 +1266,27 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* gate is re-evaluated, and a changed Search document goes out with wiki.page.updated.
* Pending AI proposals are reviewed through reviewProposal instead.
*/
-- reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
-- const space = spaceOrFail(spaceIdOrSlug);
-- const page = q.pageBySlug.get(space.id, String(slug));
++ async reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
++ await roles(actor);
++ const space = await spaceOrFail(spaceIdOrSlug);
++ const page = await q.pageBySlug.get(space.id, String(slug));
if (!page || page.state === 'deleted') fail(404, 'page.not_found', 'No such page');
if (!access.canEdit(space, actor)) fail(403, 'review.forbidden', 'Only owners and editors of this space review revisions');
const who = requirePerson(actor);
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
const n = Number(revisionNumber);
-- if (!Number.isInteger(n) || !revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
-- const prop = q.proposalByRev.get(page.id, n);
++ if (!Number.isInteger(n) || !await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
++ const prop = await q.proposalByRev.get(page.id, n);
if (prop && prop.status === 'pending') fail(409, 'review.proposal_pending', 'This revision is a pending AI proposal: approve or reject the proposal', { proposal_id: prop.id });
-- return tx(() => {
-- const b = before(page);
-- const review = wrapContentError(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));
++ return tx(async () => {
++ const b = await before(page);
++ const review = await wrapStore(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));
const out = page.state === 'published' && page.published_revision === n
-- ? sync(b, page.id, actor, { updatedIfIndexChanged: true })
++ ? await sync(b, page.id, actor, { updatedIfIndexChanged: true })
: { action: null, indexRevision: b ? b.indexRevision : null };
-- const fresh = q.pageById.get(page.id);
-- const rev = revisions.get(page.id, n);
-- const decisionNow = fresh.state === 'published' && fresh.published_revision === n ? decide(space, fresh, rev) : null;
++ const fresh = await q.pageById.get(page.id);
++ const rev = await revisions.get(page.id, n);
++ const decisionNow = fresh.state === 'published' && fresh.published_revision === n ? await decide(space, fresh, rev) : null;
return { review, page: fresh, action: out.action, indexable: decisionNow ? decisionNow.indexable : null, reasons: decisionNow ? decisionNow.codes : null };
});
},
@@@ -1202,76 +1297,80 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* (workflow + run id) and a pending proposal; nothing is published until a person approves.
* pageId null + title = propose a new page (created as a draft).
*/
-- propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
++ async propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
// A proposal may target any space, private ones included, and never needs a person: only
// first-party services (svc:…, i.e. OpenVibe.AI) file them, never a developer app or module.
const firstParty = actor && actor.kind === 'service' && /^svc:/.test(String(actor.service)) && (!actor.claims || actor.claims.actor_type === 'service');
if (!actor || (actor.kind !== 'system' && !firstParty)) fail(403, 'proposal.service_only', 'AI proposals come from a first-party service principal');
if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');
++ await roles(actor);
const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));
const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
-- return tx(() => {
-- let page = pageId ? pageOrFail(pageId) : null;
-- const space = page ? spaceOrFail(page.space_id) : spaceOrFail(spaceIdOrSlug);
++ return tx(async () => {
++ let page = pageId ? await pageOrFail(pageId) : null;
++ const space = page ? await spaceOrFail(page.space_id) : await spaceOrFail(spaceIdOrSlug);
const t = now();
let base = 0;
if (!page) {
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
-- if (q.pageBySlug.get(space.id, slug)) fail(409, 'page.slug_taken', `A page "${slug}" already exists in this space`);
++ if (await q.pageBySlug.get(space.id, slug)) fail(409, 'page.slug_taken', `A page "${slug}" already exists in this space`);
const id = pageIdNew(t);
-- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: null, position: 0, visibility: 'public', created_by: actorId(actor), now: t });
-- page = q.pageById.get(id);
++ await q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: null, position: 0, visibility: 'public', created_by: actorId(actor), now: t });
++ page = await q.pageById.get(id);
} else {
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
-- base = revisions.headNumber(page.id);
++ base = await revisions.headNumber(page.id);
if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });
}
-- const head = base ? revisions.head(page.id) : null;
++ const head = base ? await revisions.head(page.id) : null;
const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary: cleanSummary, infobox: box };
-- const { revision } = wrapContentError(() => revisions.create({
++ const { revision } = await wrapStore(() => revisions.create({
entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),
message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
}));
-- writeRevisionExtras(space, page, revision, box);
-- attachCitationList(page, revision.number, cites, actor);
++ await writeRevisionExtras(space, page, revision, box);
++ await attachCitationList(page, revision.number, cites, actor);
const id = proposalIdNew(t);
-- q.insertProposal.run({ id, page_id: page.id, space_id: space.id, revision: revision.number, base_revision: base, workflow_id: rec.workflow.id, run_id: rec.workflow.runId, stub_provider: rec.stubProvider ? 1 : 0, proposed_by: actorId(actor), note: note == null ? null : String(note).slice(0, 2000), now: t });
-- revisionEvent(space, page, revision, actor, { proposal_id: id });
-- notifyWatchers(space, page, 'proposed', revision.number, actor);
-- return { proposal: q.proposal.get(id), page: q.pageById.get(page.id), revision };
++ await q.insertProposal.run({ id, page_id: page.id, space_id: space.id, revision: revision.number, base_revision: base, workflow_id: rec.workflow.id, run_id: rec.workflow.runId, stub_provider: rec.stubProvider ? 1 : 0, proposed_by: actorId(actor), note: note == null ? null : String(note).slice(0, 2000), now: t });
++ await revisionEvent(space, page, revision, actor, { proposal_id: id });
++ await notifyWatchers(space, page, 'proposed', revision.number, actor);
++ return { proposal: await q.proposal.get(id), page: await q.pageById.get(page.id), revision };
});
},
-- proposals(pageId) { return q.proposalsOfPage.all(pageFor(pageId).id); },
-- pendingProposals(actor) {
-- return q.pendingProposals.all().filter((p) => access.canEdit(q.spaceById.get(p.space_id), actor));
++ async proposals(pageId) { return q.proposalsOfPage.all((await pageOrFail(pageId)).id); },
++ async pendingProposals(actor) {
++ await roles(actor);
++ const rows = await q.pendingProposals.all();
++ if (!rows.length) return [];
++ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));
++ return rows.filter((p) => access.canEdit(spaces.get(p.space_id), actor));
},
-- getProposal(id) { return q.proposal.get(String(id)) || null; },
++ async getProposal(id) { return (await q.proposal.get(String(id))) || null; },
/** A person approves (and by default publishes) or rejects an AI proposal. */
-- reviewProposal(id, { decision, note = null, publish = true } = {}, actor) {
-- const p = q.proposal.get(String(id));
++ async reviewProposal(id, { decision, note = null, publish = true } = {}, actor) {
++ await roles(actor);
++ const p = await q.proposal.get(String(id));
if (!p) fail(404, 'proposal.not_found', 'No such proposal');
-- const space = spaceOrFail(p.space_id);
++ const space = await spaceOrFail(p.space_id);
if (!access.canEdit(space, actor)) fail(403, 'proposal.forbidden', 'Only editors of this space review proposals');
const who = requirePerson(actor);
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'proposal.invalid_decision', 'decision is approved or rejected');
if (p.status !== 'pending') fail(409, 'proposal.already_reviewed', `This proposal was already ${p.status}`);
-- return tx(() => {
-- reviews.record({ entityId: p.page_id, revision: p.revision, reviewer: who, decision, note });
-- q.reviewProposal.run({ id: p.id, status: decision, by: who, note: note == null ? null : String(note).slice(0, 2000), now: now() });
++ return tx(async () => {
++ await reviews.record({ entityId: p.page_id, revision: p.revision, reviewer: who, decision, note });
++ await q.reviewProposal.run({ id: p.id, status: decision, by: who, note: note == null ? null : String(note).slice(0, 2000), now: now() });
let published = null;
-- if (decision === 'approved' && publish) published = publishRevision(q.pageById.get(p.page_id), p.revision, actor);
-- return { proposal: q.proposal.get(p.id), published: !!published };
++ if (decision === 'approved' && publish) published = await publishRevision(await q.pageById.get(p.page_id), p.revision, actor);
++ return { proposal: await q.proposal.get(p.id), published: !!published };
});
},
};
-- function pageFor(id) { return pageOrFail(id); }
--
return svc;
}
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 872bb8a..c9342c0 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -52,19 +52,19 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
// an edit has its own budget (30 a minute), shared by the API and the edit form; nothing is stored past it
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
const owner = { kind: 'user', subject: alice, staff: false };
-- const space = h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);
-- const { page } = h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);
-- h.svc.setRole(space.id, bob, 'editor', owner);
-- const head = () => h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;
++ const space = await h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);
++ const { page } = await h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);
++ await h.svc.setRole(space.id, bob, 'editor', owner);
++ const head = async () => (await h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id)).n;
for (let i = 0; i < 30; i++) {
-- r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: head(), body: `${H.LONG} Edit ${i}.` } });
++ r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: await head(), body: `${H.LONG} Edit ${i}.` } });
assert.ok(r.status === 200 || r.status === 201, `edit ${i + 1}: ${r.text}`);
}
-- const stored = head();
++ const stored = await head();
r = await H.req(h, 'POST', '/w/limits/counted/edit', { cookie: H.cookieFor(aliceTok), form: { expected_revision: stored, title: 'Counted', body: `${H.LONG} One more.` } });
assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.ok(r.json.detail.includes('wiki.page.edit'), r.json.detail);
-- assert.strictEqual(head(), stored, 'nothing stored');
++ assert.strictEqual(await head(), stored, 'nothing stored');
r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: bobTok, body: { expected_revision: stored, body: `${H.LONG} From bob.` } });
assert.ok(r.status === 200 || r.status === 201, `another editor still edits: ${r.text}`);
ddiff --git a/test/citation-inspector.test.js b/test/citation-inspector.test.js
iindex 08ec1a9..81fe6f9 100644
---- a/test/citation-inspector.test.js
++++ b/test/citation-inspector.test.js
@@@ -16,24 +16,24 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
const owner = { kind: 'user', subject: H.subject(), staff: false };
const cookie = H.cookieFor(H.userToken({ subject: owner.subject }));
try {
-- const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, owner);
-- const { page } = h.svc.createPage(space.id, {
++ const space = await h.svc.createSpace({ name: 'Bread', slug: 'bread' }, owner);
++ const { page } = await h.svc.createPage(space.id, {
title: 'Rye', body: H.LONG,
citations: [
{ url: 'https://flour.example/rye', title: 'Rye flour', retrievedAt: '2026-09-01T00:00:00Z', licenseNote: 'CC BY 4.0', quote: { text: 'Rye has less gluten.' } },
{ url: 'https://old.example/bread', title: 'Old bread book', retrievedAt: '2026-08-15T00:00:00Z' },
],
}, owner);
-- h.svc.publish(page.id, {}, owner);
-- const r1 = h.svc.citationsOf(page, 1);
++ await h.svc.publish(page.id, {}, owner);
++ const r1 = await h.svc.citationsOf(page, 1);
// Revision 2 keeps the first source, drops the second and adds a third.
-- h.svc.editPage(page.id, {
++ await h.svc.editPage(page.id, {
expectedRevision: 1, body: `${H.LONG} Sourdough helps.`, keepCitations: [r1[0].id],
citations: [{ url: 'https://sour.example/dough', title: 'Sourdough notes', retrievedAt: '2026-09-10T12:00:00Z' }],
}, owner);
-- h.svc.publish(page.id, {}, owner);
++ await h.svc.publish(page.id, {}, owner);
// Revision 3 is an unpublished draft citing something not public yet.
-- h.svc.editPage(page.id, { expectedRevision: 2, body: `${H.LONG} Draft.`, citations: [{ url: 'https://secret.example/draft', title: 'Unpublished source', retrievedAt: '2026-09-20T00:00:00Z' }] }, owner);
++ await h.svc.editPage(page.id, { expectedRevision: 2, body: `${H.LONG} Draft.`, citations: [{ url: 'https://secret.example/draft', title: 'Unpublished source', retrievedAt: '2026-09-20T00:00:00Z' }] }, owner);
// Linked from the article and the history page.
let r = await H.req(h, 'GET', '/w/bread/rye');
@@@ -71,28 +71,28 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
assert.ok(r.text.includes('secret.example/draft'));
// A renamed page redirects to its inspector.
-- h.svc.movePage(page.id, { slug: 'rye-bread' }, owner);
++ await h.svc.movePage(page.id, { slug: 'rye-bread' }, owner);
r = await H.req(h, 'GET', '/w/bread/rye/sources?rev=1');
assert.strictEqual(r.status, 301);
assert.strictEqual(r.headers.get('location'), '/w/bread/rye-bread/sources?rev=1');
// Same read rules as the article: private pages, members spaces, drafts and deleted pages.
-- h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
++ await h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
r = await H.req(h, 'GET', '/w/bread/rye-bread/sources');
assert.strictEqual(r.status, 404);
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.ok(!r.text.includes('flour.example'));
assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie })).status, 200, 'the owner still inspects it');
-- h.svc.setPageVisibility(page.id, { visibility: 'public' }, owner);
-- h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
++ await h.svc.setPageVisibility(page.id, { visibility: 'public' }, owner);
++ await h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources')).status, 404, 'anonymous: members space');
r = await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie: H.cookieFor(H.userToken()) });
assert.strictEqual(r.status, 200, 'any signed-in account reads a members space');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
-- h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
-- const draft = h.svc.createPage(space.id, { title: 'Spelt', body: H.LONG, citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-02' }] }, owner).page;
++ await h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
++ const draft = (await h.svc.createPage(space.id, { title: 'Spelt', body: H.LONG, citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-02' }] }, owner)).page;
assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 404, 'a draft page');
-- h.svc.deletePage(draft.id, owner);
++ await h.svc.deletePage(draft.id, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 410);
console.log('citation inspector ok');
} finally {
ddiff --git a/test/content.test.js b/test/content.test.js
iindex 038ae71..c77086b 100644
---- a/test/content.test.js
++++ b/test/content.test.js
@@@ -38,13 +38,13 @@ for (const bad of ['Site | url | javascript:alert(1)', 'N | number | lots', 'D |
const h = await H.boot();
const me = { kind: 'user', subject: H.subject() };
try {
-- const s = h.svc.createSpace({ name: '<b>Space</b>', slug: 'xss' }, me);
-- const { page } = h.svc.createPage(s.id, {
++ const s = await h.svc.createSpace({ name: '<b>Space</b>', slug: 'xss' }, me);
++ const { page } = await h.svc.createPage(s.id, {
title: '<script>alert(1)</script> title', body: `Body ${H.LONG}`, summary: '"><script>alert(2)</script>',
infobox: [{ label: '<i>L</i>', type: 'text', value: '<img src=x onerror=alert(3)>' }],
citations: [{ url: 'https://example.org/"><script>', title: '<script>alert(4)</script>', retrievedAt: '2026-09-01' }],
}, me);
-- h.svc.publish(page.id, {}, me);
++ await h.svc.publish(page.id, {}, me);
const r = await H.req(h, 'GET', `/w/xss/${page.slug}`);
assert.strictEqual(r.status, 200);
const body = r.text.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, '').replace(/<script>\s*window\.__OV_PAGE[\s\S]*?<\/script>/, '').replace(/<script>\(function\(\)\{try\{var r=localStorage[\s\S]*?<\/script>/, '');
ddiff --git a/test/db-helper.js b/test/db-helper.js
nnew file mode 100644
iindex 0000000..5bc9750
---- /dev/null
++++ b/test/db-helper.js
@@@ -0,0 +1,13 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * WIKI_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers (openvibe-sdk scripts/test-services.sh
++ * up), with roles and a schema of this run's own, shaped as OpenVibe.Host's roles/data/add-service.sh makes them.
++ */
++const { createTestDb, createTestValkey, pgAvailable, valkeyAvailable } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../server/db');
++
++const testDb = ({ store = process.env.WIKI_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'wiki', max });
++const testValkey = () => createTestValkey({ prefix: 'wiki' });
++
++module.exports = { testDb, testValkey, pgAvailable, valkeyAvailable };
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
iindex 1bcb681..59d5bcf 100644
---- a/test/delegation.test.js
++++ b/test/delegation.test.js
@@@ -35,13 +35,13 @@ const CAPS = ['wiki.space.create', 'wiki.page.create', 'wiki.page.read'];
assert.strictEqual(r.status, 403, `${type} read as the victim`);
}
}
-- assert.strictEqual(h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get().n, 0);
++ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get()).n, 0);
// An app acts for its on_behalf_of person (the header is optional and must match).
const appTok = token(APP, 'app', { on_behalf_of: appUser });
r = await H.req(h, 'POST', '/api/v1/spaces', { token: appTok, body: { name: 'App user space', slug: 'app-user' } });
assert.strictEqual(r.status, 201, r.text);
-- assert.strictEqual(h.svc.findSpace('app-user').owner, appUser);
++ assert.strictEqual((await h.svc.findSpace('app-user')).owner, appUser);
r = await H.req(h, 'POST', '/api/v1/spaces/app-user/pages', { token: appTok, headers: { 'X-OV-Subject': appUser }, body: { title: 'Mine', body: H.LONG } });
assert.strictEqual(r.status, 201, r.text);
r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: appTok });
ddiff --git a/test/discussion-visibility.test.js b/test/discussion-visibility.test.js
iindex fc59e5d..64021d6 100644
---- a/test/discussion-visibility.test.js
++++ b/test/discussion-visibility.test.js
@@@ -25,7 +25,7 @@ const H = require('./helpers');
const vis = u.pathname.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
if (vis && method === 'PUT') {
const headers = Object.fromEntries(new Headers(init.headers || {}).entries());
-- calls.push({ thread: vis[1], body: JSON.parse(init.body), headers, inTransaction: h.db.inTransaction });
++ calls.push({ thread: vis[1], body: JSON.parse(init.body), headers, inTransaction: h.db.inTransaction() });
if (communityDown) return json(503, { code: 'unavailable' });
return json(200, { thread: { id: Number(vis[1]), visibility: JSON.parse(init.body).visibility } });
}
@@@ -58,7 +58,7 @@ const H = require('./helpers');
// Rendering the public page resolves its thread (id 1).
r = await H.req(h, 'GET', '/w/talk/discussed');
assert.strictEqual(r.status, 200);
-- assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '1');
++ assert.strictEqual((await h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId)).thread_id, '1');
// Unpublished: hidden. After the commit, with a moderate token.
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/unpublish`, { token: tok, body: {} });
@@@ -115,7 +115,7 @@ const H = require('./helpers');
assert.strictEqual(r.status, 200, r.text);
await settle(7);
assert.strictEqual(calls.length, 7);
-- assert.strictEqual(h.svc.pageById(pageId).state, 'unpublished');
++ assert.strictEqual((await h.svc.pageById(pageId)).state, 'unpublished');
communityDown = false;
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
await settle(8);
ddiff --git a/test/feeds.test.js b/test/feeds.test.js
iindex fea9094..a53be32 100644
---- a/test/feeds.test.js
++++ b/test/feeds.test.js
@@@ -60,27 +60,27 @@ function atom(text) {
assert.deepStrictEqual(f.entries, []);
// Published, but nothing listable: an unreviewed AI-assisted import, a private page, a draft.
-- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
++ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
-- const seeded = h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM).page;
-- h.svc.publish(seeded.id, {}, SYSTEM);
-- const hidden = h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner).page;
-- h.svc.publish(hidden.id, {}, owner);
-- h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);
-- const secret = h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);
-- const inSecret = h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner).page;
-- h.svc.publish(inSecret.id, {}, owner);
++ const seeded = (await h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM)).page;
++ await h.svc.publish(seeded.id, {}, SYSTEM);
++ const hidden = (await h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner)).page;
++ await h.svc.publish(hidden.id, {}, owner);
++ await h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);
++ const secret = await h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);
++ const inSecret = (await h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner)).page;
++ await h.svc.publish(inSecret.id, {}, owner);
r = await H.req(h, 'GET', '/feed.atom');
assert.strictEqual(r.status, 200);
f = atom(r.text);
assert.deepStrictEqual(f.entries, []);
-- assert.strictEqual(f.updated, new Date(h.svc.findSpace('notes').updated_at).toISOString(), 'the last change to a public space');
++ assert.strictEqual(f.updated, new Date((await h.svc.findSpace('notes')).updated_at).toISOString(), 'the last change to a public space');
assert.ok(!/imported-page|private-page|draft-page|hidden-plans/.test(r.text), 'nothing unlisted leaks into the feed');
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
// A listable page: one entry, and the feed's <updated> is that entry's.
-- const open = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;
-- h.svc.publish(open.id, {}, owner);
++ const open = (await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner)).page;
++ await h.svc.publish(open.id, {}, owner);
f = atom((await H.req(h, 'GET', '/feed.atom')).text);
assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);
assert.strictEqual(f.updated, f.entries[0].updated);
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 89ed98a..a536125 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -10,6 +10,7 @@ const path = require('path');
const { serviceAuth } = require('openvibe-contracts');
const { load } = require('../server/config');
const { start } = require('../server/index');
++const { testDb } = require('./db-helper');
const ISSUER = 'https://network.test';
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
@@@ -32,14 +33,20 @@ function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.wiki', exp = 30
return serviceAuth.signServiceToken({ iss: ISSUER, sub: sub || `svc:${client}`, actor_type: actorType, aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...app, ...extra }, privateKey);
}
--const quiet = { log() {}, warn() {}, error() {} };
++const quiet = process.env.WIKI_TEST_LOG ? console : { log() {}, warn() {}, error() {} };
/**
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* (Community, Sources, Media, Events, Network token endpoint).
*/
--async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {
++/**
++ * A running Wiki on a database of its own (PGlite by default; WIKI_TEST_STORE=pg: the PostgreSQL + PgBouncer
++ * containers). opts.db: boot on an existing handle (a restart keeps the data); h.stop() closes what boot opened.
++ */
++async function boot({ env = {}, fetch: fetchImpl, dbPath, db: givenDb = null, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
++ const owned = givenDb ? null : await testDb();
++ const db = givenDb || owned.db;
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
WIKI_DB_PATH: dbPath || path.join(dir, 'wiki.db'),
@@@ -47,12 +54,13 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
...env,
});
const h = await start({
-- config, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,
++ config, db, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
const base = `http://127.0.0.1:${h.server.address().port}`;
-- return { ...h, base, dir, dbPath: config.dbPath };
++ const stop = async ({ keepDb = false } = {}) => { await h.stop(); if (owned && !keepDb) await owned.close(); };
++ return { ...h, stop, base, dir, dbPath: config.dbPath, closeDb: owned ? owned.close : async () => {} };
}
async function req(h, method, p, { token, body, form, headers = {}, cookie } = {}) {
@@@ -77,8 +85,8 @@ async function req(h, method, p, { token, body, form, headers = {}, cookie } = {
const cookieFor = (token) => `ov_token=${token}`;
/** Every envelope in the outbox, oldest first. */
--function outbox(h) {
-- return h.db.prepare('SELECT envelope FROM wiki_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
++async function outbox(h) {
++ return (await h.db.prepare('SELECT envelope FROM wiki_event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
}
const LONG = 'This page has enough words to pass the thin content rule of the indexability gate, which the tests set to twenty words so that a short paragraph is enough for a published page to be indexable.';
ddiff --git a/test/import.test.js b/test/import.test.js
iindex 9ba9b80..d1bf6d3 100644
---- a/test/import.test.js
++++ b/test/import.test.js
@@@ -9,7 +9,7 @@
const assert = require('assert');
const H = require('./helpers');
--const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
++const count = async (h, table) => (await h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).n;
(async () => {
const h = await H.boot();
@@@ -33,7 +33,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
await H.req(h, 'POST', '/api/v1/spaces', { token: tokEditor, body: { name: 'Secret', slug: 'secret', visibility: 'private' } });
// Only an owner, acting as a person, imports.
-- const pagesBefore = count(h, 'wiki_pages');
++ const pagesBefore = await count(h, 'wiki_pages');
r = await imp(tokEditor, bundle);
assert.strictEqual(r.status, 403, 'an editor is not an owner');
assert.strictEqual(r.json.code, 'import.forbidden');
@@@ -43,7 +43,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
assert.strictEqual(r.status, 403, 'a service acting as itself is no owner');
r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.read'] }), bundle);
assert.strictEqual(r.status, 403, 'the route needs wiki.page.create');
-- assert.strictEqual(count(h, 'wiki_pages'), pagesBefore);
++ assert.strictEqual(await count(h, 'wiki_pages'), pagesBefore);
// Invalid and oversized bundles are refused before anything is written.
const refusals = [
@@@ -73,12 +73,12 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
r = await imp(tokOwner, { pages: Array.from({ length: 150 }, (_x, i) => ({ title: `Page ${i}`, body: 'y'.repeat(15000) })) });
assert.strictEqual(r.status, 413, 'a bundle over 2 MB');
assert.strictEqual(r.json.code, 'request.too_large');
-- assert.strictEqual(count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');
++ assert.strictEqual(await count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');
// Atomicity: the third page is invalid (an infobox value that is not a number; a parent that
// is nowhere) → none of the pages, revisions, citations or events exist afterwards.
-- const snapshot = () => ['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t));
-- const before = snapshot();
++ const snapshot = async () => Promise.all(['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t)));
++ const before = await snapshot();
for (const broken of [
{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', infobox: [{ label: 'Weight', type: 'number', value: 'heavy' }] }] },
{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', parent: 'Nowhere' }] },
@@@ -87,7 +87,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
]) {
r = await imp(tokOwner, broken);
assert.ok(r.status === 422, `${r.status} ${r.text.slice(0, 200)}`);
-- assert.deepStrictEqual(snapshot(), before, `all or nothing: ${r.json.code}`);
++ assert.deepStrictEqual(await snapshot(), before, `all or nothing: ${r.json.code}`);
}
// Success: drafts, attributed to the importer, links and citations recorded, events emitted.
@@@ -95,19 +95,19 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
assert.strictEqual(r.status, 201, r.text);
assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state, p.revision]), [['bread', 'draft', 1], ['private-notes', 'draft', 1], ['rye', 'draft', 1]], 'parents first');
assert.strictEqual(r.json.published, false);
-- const space = h.svc.findSpace('bakery');
-- const rye = h.svc.findPage(space.id, 'rye');
-- const bread = h.svc.findPage(space.id, 'bread');
++ const space = await h.svc.findSpace('bakery');
++ const rye = await h.svc.findPage(space.id, 'rye');
++ const bread = await h.svc.findPage(space.id, 'bread');
assert.strictEqual(rye.parent_id, bread.id, 'the parent came from the bundle');
-- assert.strictEqual(h.svc.findPage(space.id, 'private-notes').visibility, 'private');
-- const rev = h.stores.revisions.get(rye.id, 1);
++ assert.strictEqual((await h.svc.findPage(space.id, 'private-notes')).visibility, 'private');
++ const rev = await h.stores.revisions.get(rye.id, 1);
assert.strictEqual(rev.author, owner);
assert.deepStrictEqual(rev.meta.authorship, { mode: 'imported', authors: [owner], importedFrom: { label: 'the old bakery wiki', originalAuthor: null } });
-- assert.strictEqual(h.svc.citationsOf(rye, 1)[0].url, 'https://flour.example/rye');
-- assert.ok(h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');
-- const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));
++ assert.strictEqual((await h.svc.citationsOf(rye, 1))[0].url, 'https://flour.example/rye');
++ assert.ok(await h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');
++ const created = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));
assert.strictEqual(created.length, 2);
-- assert.ok(h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');
++ assert.ok(await h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');
// Drafts: invisible to readers, visible to editors; the markdown is rendered by the usual sanitiser.
assert.strictEqual((await H.req(h, 'GET', '/w/bakery/rye')).status, 404);
r = await H.req(h, 'GET', '/w/bakery/rye', { cookie: H.cookieFor(tokEditor) });
@@@ -121,18 +121,18 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
r = await imp(tokOwner, again);
assert.strictEqual(r.status, 409);
assert.strictEqual(r.json.code, 'import.pages_exist');
-- assert.strictEqual(h.svc.findPage(space.id, 'spelt'), null);
++ assert.strictEqual(await h.svc.findPage(space.id, 'spelt'), null);
r = await imp(tokOwner, { ...again, on_existing: 'skip', publish: true });
assert.strictEqual(r.status, 201, r.text);
assert.deepStrictEqual(r.json.skipped, [{ slug: 'rye', title: 'Rye', reason: 'exists' }]);
assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state]), [['spelt', 'published']]);
-- assert.strictEqual(h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');
++ assert.strictEqual(await h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');
// Published through the import: readable, indexed like any page (upsert event).
assert.strictEqual((await H.req(h, 'GET', '/w/bakery/spelt')).status, 200);
-- const spelt = h.svc.findPage(space.id, 'spelt');
++ const spelt = await h.svc.findPage(space.id, 'spelt');
assert.strictEqual(spelt.parent_id, bread.id, 'a parent that already exists in the space');
-- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));
-- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));
++ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));
++ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));
// AI-assisted bundles stay noindex until a person reviews each page.
r = await imp(tokOwner, { ai_assisted: true, publish: true, source: 'a model', pages: [{ title: 'Barley', body: `Barley bread. ${H.LONG}`, citations: [{ url: 'https://barley.example/', retrievedAt: '2026-09-04' }] }] });
@@@ -160,8 +160,8 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
assert.strictEqual(r.status, 403);
r = await H.req(h, 'POST', '/s/bakery/import', { cookie, headers: { Origin: 'https://evil.example' }, form: { bundle: JSON.stringify([{ title: 'Csrf', body: 'x' }]) } });
assert.strictEqual(r.status, 403, 'cross-site form posts are refused');
-- assert.strictEqual(h.svc.findPage(space.id, 'nope'), null);
-- assert.strictEqual(h.svc.findPage(space.id, 'csrf'), null);
++ assert.strictEqual(await h.svc.findPage(space.id, 'nope'), null);
++ assert.strictEqual(await h.svc.findPage(space.id, 'csrf'), null);
console.log('import ok');
} finally {
await h.stop();
ddiff --git a/test/integrations.test.js b/test/integrations.test.js
iindex ef293a2..feaa9ab 100644
---- a/test/integrations.test.js
++++ b/test/integrations.test.js
@@@ -76,7 +76,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
sourcesUp = false;
r = await H.req(h, 'POST', '/api/v1/spaces/mt/pages', { token: tok, body: { title: 'Down', body: H.LONG, citations: [{ source_item_id: ITEM }] } });
assert.strictEqual(r.status, 503);
-- assert.strictEqual(h.svc.findPage(h.svc.findSpace('mt').id, 'down'), null, 'nothing is written without the source');
++ assert.strictEqual(await h.svc.findPage((await h.svc.findSpace('mt')).id, 'down'), null, 'nothing is written without the source');
sourcesUp = true;
await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
@@@ -108,8 +108,8 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
const resolves = calls.filter((c) => c.url === '/api/v1/comments/threads/resolve');
assert.strictEqual(resolves.length, 1);
assert.deepStrictEqual(JSON.parse(resolves[0].body).ref, { service: 'wiki', type: 'page', id: pageId, label: 'Cited' });
-- assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '7');
-- assert.deepStrictEqual(h.db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'no copy of comment content');
++ assert.strictEqual((await h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId)).thread_id, '7');
++ assert.deepStrictEqual((await h.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'wiki_discussion_refs'").all()).map((c) => c.name).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'no copy of comment content');
r = await H.req(h, 'POST', '/w/mt/cited/discuss', { cookie: H.cookieFor(tok), form: { message: 'Thanks' } });
assert.strictEqual(r.status, 303);
const post = calls.find((c) => c.url === '/api/v1/comments/threads/7/comments');
@@@ -119,11 +119,11 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
assert.strictEqual(r.status, 401, 'anonymous visitors do not post through Wiki');
// Events: the relay publishes the outbox with the service token.
-- const pending = h.platform.outbox.pending();
++ const pending = await h.platform.outbox.pending();
assert.ok(pending > 0);
const flushed = await h.platform.outbox.flush();
assert.strictEqual(flushed.sent, pending);
-- assert.strictEqual(h.platform.outbox.pending(), 0);
++ assert.strictEqual(await h.platform.outbox.pending(), 0);
const ev = calls.filter((c) => c.host === 'events.internal');
assert.ok(ev.length >= 1);
assert.strictEqual(ev[0].headers.authorization, 'Bearer stub-token');
ddiff --git a/test/media-handoff.test.js b/test/media-handoff.test.js
iindex 70456e5..46be0a3 100644
---- a/test/media-handoff.test.js
++++ b/test/media-handoff.test.js
@@@ -55,12 +55,12 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
assert.strictEqual(r.json.attachment.state, 'available');
assert.strictEqual(r.json.attachment.attached_by, A);
assert.strictEqual((await attach(tokA, pageId, MED(2))).status, 201, 'unlisted objects are readable by anyone with the id');
-- const origins = h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);
++ const origins = await h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);
assert.deepStrictEqual(origins, [
{ media_id: MED(1), attached_by: A, media_owner: A, media_visibility: 'public' },
{ media_id: MED(2), attached_by: A, media_owner: C, media_visibility: 'unlisted' },
]);
-- assert.throws(() => h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);
++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);
r = await attach(tokA, pageId, MED(4));
assert.strictEqual(r.status, 409);
assert.strictEqual(r.json.code, 'media.not_ready');
@@@ -76,7 +76,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
assert.strictEqual(r.status, 201, r.text);
r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: tokB });
assert.deepStrictEqual(r.json.attachments.map((a) => [a.media_id, a.attached_by, a.state]), [[MED(1), A, 'available'], [MED(2), A, 'available']]);
-- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2);
++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId)).n, 2);
// 3. B cannot attach an object only A can read — the same answer as an id that does not exist.
const refused = await attach(tokB, pageId, MED(3));
@@@ -106,7 +106,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
assert.strictEqual(r.status, 503);
mediaUp = true;
await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'user', subject: A }, {}), (e) => e.status === 503, 'no Media configured: nothing is attached');
-- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2, 'nothing refused was attached');
++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId)).n, 2, 'nothing refused was attached');
// 4. Media deletes one object and makes the other private: after the check, the public page
// shows the explicit states and no image.
ddiff --git a/test/nojs.test.js b/test/nojs.test.js
iindex baa4278..7515101 100644
---- a/test/nojs.test.js
++++ b/test/nojs.test.js
@@@ -35,7 +35,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'preview', title: 'Chess', body, infobox: 'Players | number | 2\nInvented | text | unknown' } });
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('Preview (not saved)'));
-- assert.strictEqual(h.svc.findPage(h.svc.findSpace('board-games').id, 'chess'), null);
++ assert.strictEqual(await h.svc.findPage((await h.svc.findSpace('board-games')).id, 'chess'), null);
// Invalid infobox rows come back with the text intact.
r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'save', title: 'Chess', body, infobox: 'Players | number | two' } });
assert.strictEqual(r.status, 422);
ddiff --git a/test/permissions.test.js b/test/permissions.test.js
iindex 77a88d4..e8ea1d8 100644
---- a/test/permissions.test.js
++++ b/test/permissions.test.js
@@@ -73,10 +73,10 @@ const H = require('./helpers');
// Staff get no silent access to a private user space.
assert.strictEqual((await H.req(h, 'GET', '/w/garden/tomatoes', { cookie: H.cookieFor(tok(staff, 'admin')) })).status, 404);
// A watcher who loses access is not notified.
-- h.svc.watch(pageId, { kind: 'user', subject: viewer }, true);
-- h.db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)').run(pageId, stranger, Date.now());
-- h.svc.editPage(pageId, { expectedRevision: 1, body: `${H.LONG} More.` }, { kind: 'user', subject: editor });
-- const watch = H.outbox(h).filter((e) => e.event_type === 'wiki.watch.triggered').pop();
++ await h.svc.watch(pageId, { kind: 'user', subject: viewer }, true);
++ await h.db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)').run(pageId, stranger, Date.now());
++ await h.svc.editPage(pageId, { expectedRevision: 1, body: `${H.LONG} More.` }, { kind: 'user', subject: editor });
++ const watch = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.watch.triggered').pop();
assert.ok(watch.payload.recipients.includes(viewer));
assert.ok(!watch.payload.recipients.includes(stranger), 'no notification about a page the person cannot read');
assert.ok(!watch.payload.recipients.includes(editor), 'the actor is not notified of their own change');
@@@ -147,7 +147,7 @@ const H = require('./helpers');
assert.ok(shown.text.includes('Proposed by a workflow'));
assert.ok(shown.text.includes('AI-generated'), 'the AI disclosure is shown at the item');
assert.ok(shown.text.includes('reviewed by a person'));
-- const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === pageId).pop().payload;
++ const doc = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === pageId).pop().payload;
assert.strictEqual(doc.authorship, 'ai_generated');
assert.ok(doc.provenance.some((p) => p.service === 'ai' && p.type === 'run' && p.id === 'run_01'));
r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: tok(editor), body: { decision: 'rejected' } });
@@@ -163,7 +163,7 @@ const H = require('./helpers');
const p2 = r.json.proposal;
r = await H.req(h, 'POST', `/api/v1/proposals/${p2.id}/review`, { token: tok(editor), body: { decision: 'rejected', note: 'not sourced' } });
assert.strictEqual(r.json.published, false);
-- assert.strictEqual(h.svc.pageById(p2.page_id).state, 'draft');
++ assert.strictEqual((await h.svc.pageById(p2.page_id)).state, 'draft');
assert.strictEqual((await H.req(h, 'GET', '/w/garden/peppers')).status, 404);
console.log('permissions ok');
} finally {
ddiff --git a/test/projects-module.test.js b/test/projects-module.test.js
iindex c2d415c..1d62aac 100644
---- a/test/projects-module.test.js
++++ b/test/projects-module.test.js
@@@ -22,29 +22,29 @@ const { summarize, createProjectsModule } = require('../server/integrations/proj
return { ok: true, status: 201, json: async () => ({}) };
};
const projects = createProjectsModule({ db: h.db, config: { networkInternalUrl: 'http://network.test' }, tokens: h.platform.tokenClient, fetchImpl, log: H.quiet });
-- const dirty = () => h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all().map((r) => r.subject);
++ const dirty = async () => (await h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all()).map((r) => r.subject);
try {
let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });
assert.strictEqual(r.status, 201, r.text);
r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Diary', slug: 'diary', visibility: 'private' } });
assert.strictEqual(r.status, 201, r.text);
-- assert.deepStrictEqual(dirty(), [ann], 'creating a space marks its owner');
++ assert.deepStrictEqual(await dirty(), [ann], 'creating a space marks its owner');
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: 'editor' } });
assert.strictEqual(r.status, 200, r.text);
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${cat}`, { token: tok(ann), body: { role: 'viewer' } });
assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });
-- assert.deepStrictEqual(summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });
-- assert.ok(modules.validateData('wiki.projects', summarize(h.db, ann)).valid, 'matches the namespace schema');
++ assert.deepStrictEqual(await summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });
++ assert.deepStrictEqual(await summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });
++ assert.ok(modules.validateData('wiki.projects', await summarize(h.db, ann)).valid, 'matches the namespace schema');
assert.strictEqual(await projects.drain(), 2, 'ann and bob; cat is only a viewer');
assert.deepStrictEqual(puts.map((p) => p.subject).sort(), [ann, bob].sort());
-- assert.deepStrictEqual(dirty(), [], 'every mark cleared');
++ assert.deepStrictEqual(await dirty(), [], 'every mark cleared');
// Bob loses his role: he is marked although he is no longer in the space's list.
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: null } });
assert.strictEqual(r.status, 200, r.text);
-- assert.ok(dirty().includes(bob));
++ assert.ok((await dirty()).includes(bob));
await projects.drain();
assert.deepStrictEqual(puts.at(-1), { subject: bob, data: { spaces: [], private_count: 0 } }, 'his record empties');
@@@ -55,7 +55,7 @@ const { summarize, createProjectsModule } = require('../server/integrations/proj
await projects.drain();
assert.strictEqual(puts.length, before + 1);
assert.strictEqual(puts.at(-1).data.spaces[0].name, 'Big Garden');
-- h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);
++ await h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);
await projects.drain();
assert.strictEqual(puts.length, before + 1, 'unchanged: not written again');
assert.strictEqual(projects.stats().unchanged >= 2, true);
ddiff --git a/test/review.test.js b/test/review.test.js
iindex fca0aef..d40667c 100644
---- a/test/review.test.js
++++ b/test/review.test.js
@@@ -19,25 +19,25 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
const staff = H.subject(), editor = H.subject(), viewer = H.subject(), stranger = H.subject();
const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });
try {
-- seed(h.svc, data, { log: H.quiet });
-- const space = h.svc.findSpace('openvibe');
++ await seed(h.svc, data, { log: H.quiet });
++ const space = await h.svc.findSpace('openvibe');
// A row seeded before the flag existed: recognised by its label alone.
-- const legacy = h.svc.createPage(space.id, {
++ const legacy = await h.svc.createPage(space.id, {
title: 'Legacy import', body: `${data.pages[1].body}`, citations: [{ url: data.pages[1].citations[0].url, retrievedAt: '2026-09-22T12:00:00Z' }],
authorship: { mode: 'imported', importedFrom: { label: 'the READMEs, summarised with AI assistance', originalAuthor: 'OpenVibers' } },
}, SYSTEM);
-- h.svc.publish(legacy.page.id, {}, SYSTEM);
-- assert.strictEqual(h.stores.revisions.get(legacy.page.id, 1).meta.authorship.importedFrom.aiAssisted, undefined);
-- assert.deepStrictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.codes, ['ai_generated_unreviewed']);
++ await h.svc.publish(legacy.page.id, {}, SYSTEM);
++ assert.strictEqual((await h.stores.revisions.get(legacy.page.id, 1)).meta.authorship.importedFrom.aiAssisted, undefined);
++ assert.deepStrictEqual((await h.svc.publishedPublic()).find((x) => x.page.id === legacy.page.id).decision.codes, ['ai_generated_unreviewed']);
-- h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });
-- h.svc.setRole(space.id, viewer, 'viewer', { kind: 'user', subject: staff, staff: true });
-- const page = h.svc.findPage(space.id, 'openvibe-search');
++ await h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });
++ await h.svc.setRole(space.id, viewer, 'viewer', { kind: 'user', subject: staff, staff: true });
++ const page = await h.svc.findPage(space.id, 'openvibe-search');
const n = page.published_revision;
const url = `/api/v1/pages/${page.id}/revisions/${n}/review`;
-- const indexEvents = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.'));
-- const updates = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type === 'wiki.page.updated');
-- const before = { index: indexEvents().length, updates: updates().length };
++ const indexEvents = async () => (await H.outbox(h)).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.'));
++ const updates = async () => (await H.outbox(h)).filter((e) => e.subject.id === page.id && e.event_type === 'wiki.page.updated');
++ const before = { index: (await indexEvents()).length, updates: (await updates()).length };
// Refused: anonymous, a signed-in stranger, a viewer, a service acting as itself, a service without the capability.
assert.strictEqual((await H.req(h, 'POST', url, { body: { decision: 'approved' } })).status, 403);
@@@ -50,8 +50,8 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.strictEqual((await H.req(h, 'POST', url, { token: tok(editor), body: { decision: 'maybe' } })).status, 422);
assert.strictEqual((await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/99/review`, { token: tok(editor), body: { decision: 'approved' } })).status, 404);
// Nothing changed.
-- assert.strictEqual(h.stores.reviews.history(page.id).length, 0);
-- assert.deepStrictEqual({ index: indexEvents().length, updates: updates().length }, before);
++ assert.strictEqual((await h.stores.reviews.history(page.id)).length, 0);
++ assert.deepStrictEqual({ index: (await indexEvents()).length, updates: (await updates()).length }, before);
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
// The history view offers the review form to editors only.
@@@ -67,18 +67,18 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
r = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(editor)), form: { revision: String(n), decision: 'approved', note: 'Checked against the README' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.headers.get('location'), '/w/openvibe/openvibe-search/history');
-- const reviews = h.stores.reviews.history(page.id);
++ const reviews = await h.stores.reviews.history(page.id);
assert.strictEqual(reviews.length, 1);
assert.deepStrictEqual([reviews[0].reviewer, reviews[0].decision, reviews[0].note], [editor, 'approved', 'Checked against the README']);
// Indexable now; the Search document went out exactly once, with wiki.page.updated.
-- const idx = indexEvents().slice(before.index);
++ const idx = (await indexEvents()).slice(before.index);
assert.strictEqual(idx.length, 1);
assert.strictEqual(idx[0].event_type, 'wiki.index_document.upserted');
assert.strictEqual(idx[0].payload.indexability.decision, 'index');
-- assert.ok(idx[0].subject.revision > indexEvents()[before.index - 1].subject.revision, 'the sequencer bumped the index revision');
++ assert.ok(idx[0].subject.revision > (await indexEvents())[before.index - 1].subject.revision, 'the sequencer bumped the index revision');
assert.ok(contracts.validate('search.index-document@1', idx[0].payload).valid);
-- const upd = updates().slice(before.updates);
++ const upd = (await updates()).slice(before.updates);
assert.strictEqual(upd.length, 1);
assert.strictEqual(upd[0].visibility, 'public');
assert.strictEqual(upd[0].payload.indexability.decision, 'index');
@@@ -97,30 +97,30 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-events'));
// A second identical approval records the review but sends nothing new (same document).
-- const count = indexEvents().length;
++ const count = (await indexEvents()).length;
r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.indexable, true);
assert.strictEqual(r.json.action, null);
-- assert.strictEqual(indexEvents().length, count);
++ assert.strictEqual((await indexEvents()).length, count);
// "Needs changes" takes it out again: tombstone + wiki.page.updated, gone from the sitemap.
r = await H.req(h, 'POST', url, { token: tok(staff, 'admin'), body: { decision: 'rejected', note: 'Status is out of date' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.indexable, false);
assert.deepStrictEqual(r.json.reasons, ['ai_generated_unreviewed']);
-- assert.strictEqual(indexEvents().pop().event_type, 'wiki.index_document.deleted');
-- assert.strictEqual(updates().length, before.updates + 2);
++ assert.strictEqual((await indexEvents()).pop().event_type, 'wiki.index_document.deleted');
++ assert.strictEqual((await updates()).length, before.updates + 2);
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
assert.ok((await H.req(h, 'GET', '/w/openvibe/openvibe-search')).text.includes('Not yet reviewed by a person.'));
// The legacy-labelled row is reviewable the same way.
-- h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });
-- assert.strictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.indexable, true);
++ await h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });
++ assert.strictEqual((await h.svc.publishedPublic()).find((x) => x.page.id === legacy.page.id).decision.indexable, true);
// Human revisions and AI proposals are unaffected: a pending proposal goes through its own review.
-- const prop = h.svc.propose({ space: 'openvibe', pageId: page.id, body: `${data.pages[6].body} x`, workflow: { id: 'wiki.generate_page', runId: 'run_9' } }, { kind: 'service', service: 'svc:ai', subject: null });
-- assert.throws(() => h.svc.reviewRevision(space.id, page.slug, prop.revision.number, { decision: 'approved' }, { kind: 'user', subject: editor }), (e) => e.code === 'review.proposal_pending');
++ const prop = await h.svc.propose({ space: 'openvibe', pageId: page.id, body: `${data.pages[6].body} x`, workflow: { id: 'wiki.generate_page', runId: 'run_9' } }, { kind: 'service', service: 'svc:ai', subject: null });
++ await assert.rejects(async () => await h.svc.reviewRevision(space.id, page.slug, prop.revision.number, { decision: 'approved' }, { kind: 'user', subject: editor }), (e) => e.code === 'review.proposal_pending');
console.log('review ok');
} finally {
await h.stop();
@@@ -131,21 +131,22 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
(async () => {
const { seed: seedFn } = require('../server/wiki/seed');
const h1 = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
-- const dbPath = h1.dbPath;
-- seedFn(h1.svc, data, { log: H.quiet });
-- const page = h1.svc.findPage(h1.svc.findSpace('openvibe').id, 'openvibe-network');
++ const shared = h1.db;
++ await seedFn(h1.svc, data, { log: H.quiet });
++ const page = await h1.svc.findPage((await h1.svc.findSpace('openvibe')).id, 'openvibe-network');
// Simulate the host: Search was last sent an indexable document for this page.
-- const old = h1.db.prepare("SELECT revision FROM wiki_index_revisions WHERE id = ?").get(page.id).revision;
-- h1.db.prepare("UPDATE wiki_index_revisions SET hash = 'sent-under-the-old-rule' WHERE id = ?").run(page.id);
-- await h1.stop();
-- const h2 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });
-- const ev = H.outbox(h2).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.')).pop();
++ const old = (await h1.db.prepare("SELECT revision FROM wiki_index_revisions WHERE id = ?").get(page.id)).revision;
++ await h1.db.prepare("UPDATE wiki_index_revisions SET hash = 'sent-under-the-old-rule' WHERE id = ?").run(page.id);
++ await h1.stop({ keepDb: true });
++ const h2 = await H.boot({ db: shared, env: { WIKI_GATE_MIN_WORDS: '80' } });
++ const ev = (await H.outbox(h2)).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.')).pop();
assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
assert.ok(ev.subject.revision > old);
-- const n = H.outbox(h2).length;
-- await h2.stop();
-- const h3 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });
-- assert.strictEqual(H.outbox(h3).length, n, 'a second boot sends nothing');
++ const n = (await H.outbox(h2)).length;
++ await h2.stop({ keepDb: true });
++ const h3 = await H.boot({ db: shared, env: { WIKI_GATE_MIN_WORDS: '80' } });
++ assert.strictEqual((await H.outbox(h3)).length, n, 'a second boot sends nothing');
await h3.stop();
++ await h1.closeDb();
console.log('reconcile ok');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/revisions.test.js b/test/revisions.test.js
iindex 431f011..4add09c 100644
---- a/test/revisions.test.js
++++ b/test/revisions.test.js
@@@ -10,12 +10,12 @@ const H = require('./helpers');
const h = await H.boot();
const alice = { kind: 'user', subject: H.subject(), staff: false };
try {
-- const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, alice);
++ const space = await h.svc.createSpace({ name: 'Bread', slug: 'bread' }, alice);
const src1 = { url: 'https://example.org/rye', title: 'Rye notes', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass.' } };
const src2 = { url: 'https://example.org/wheat', title: 'Wheat notes', retrievedAt: '2026-09-21T10:00:00Z' };
// r1: created with a citation and a typed infobox
-- const { page, revision: r1 } = h.svc.createPage(space.id, {
++ const { page, revision: r1 } = await h.svc.createPage(space.id, {
title: 'Rye bread', body: `# Rye bread\n\n${H.LONG}`, citations: [src1],
infobox: [{ label: 'Flour', type: 'text', value: 'rye' }, { label: 'Hydration', type: 'number', value: '78' }, { label: 'Sourdough', type: 'boolean', value: 'yes' }],
}, alice);
@@@ -23,11 +23,11 @@ const H = require('./helpers');
assert.strictEqual(page.state, 'draft');
// publish r1
-- h.svc.publish(page.id, { revision: 1 }, alice);
-- assert.strictEqual(h.svc.pageById(page.id).published_revision, 1);
++ await h.svc.publish(page.id, { revision: 1 }, alice);
++ assert.strictEqual((await h.svc.pageById(page.id)).published_revision, 1);
// r2: edit drops src1, adds src2, changes the infobox
-- const e2 = h.svc.editPage(page.id, {
++ const e2 = await h.svc.editPage(page.id, {
expectedRevision: 1, body: `# Rye bread\n\nA different text. ${H.LONG}`, citations: [src2], keepCitations: 'none',
infobox: [{ label: 'Flour', type: 'text', value: 'rye and wheat' }],
}, alice);
@@@ -35,20 +35,20 @@ const H = require('./helpers');
assert.strictEqual(e2.created, true);
// Optimistic concurrency: an edit based on r1 is a 412 conflict.
-- assert.throws(() => h.svc.editPage(page.id, { expectedRevision: 1, body: 'stale edit' }, alice), (err) => err.status === 412 && err.code === 'revision.conflict');
++ await assert.rejects(async () => await h.svc.editPage(page.id, { expectedRevision: 1, body: 'stale edit' }, alice), (err) => err.status === 412 && err.code === 'revision.conflict');
// An identical edit creates nothing.
-- const same = h.svc.editPage(page.id, { expectedRevision: 2 }, alice);
++ const same = await h.svc.editPage(page.id, { expectedRevision: 2 }, alice);
assert.strictEqual(same.created, false);
// Citations stay with their revision.
-- const c1 = h.svc.citationsOf(page, 1);
-- const c2 = h.svc.citationsOf(page, 2);
++ const c1 = await h.svc.citationsOf(page, 1);
++ const c2 = await h.svc.citationsOf(page, 2);
assert.deepStrictEqual(c1.map((c) => c.url), ['https://example.org/rye']);
assert.deepStrictEqual(c2.map((c) => c.url), ['https://example.org/wheat']);
assert.strictEqual(c1[0].retrievedAt, '2026-09-20T10:00:00.000Z');
assert.deepStrictEqual(c1[0].quote, { text: 'Rye is a grass.', start: null, end: null });
// The published page (still r1) shows r1's sources, not r2's.
-- const pub = h.svc.view(space, h.svc.pageById(page.id), alice, {});
++ const pub = await h.svc.view(space, await h.svc.pageById(page.id), alice, {});
assert.strictEqual(pub.revision.number, 1);
assert.deepStrictEqual(pub.citations.map((c) => c.url), ['https://example.org/rye']);
assert.deepStrictEqual(pub.infobox, [
@@@ -58,40 +58,40 @@ const H = require('./helpers');
]);
// Citations cannot be attached to the published revision after the fact.
-- assert.throws(() => h.svc.attachCitations(page.id, 1, [src2], alice), (err) => err.status === 409);
++ await assert.rejects(async () => await h.svc.attachCitations(page.id, 1, [src2], alice), (err) => err.status === 409);
// ...but can be to the unpublished head.
-- h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/extra', retrievedAt: '2026-09-22T00:00:00Z' }], alice);
-- assert.strictEqual(h.svc.citationsOf(page, 2).length, 2);
++ await h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/extra', retrievedAt: '2026-09-22T00:00:00Z' }], alice);
++ assert.strictEqual((await h.svc.citationsOf(page, 2)).length, 2);
// A URL citation without a retrieval time is refused (never invented).
-- assert.throws(() => h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/no-date' }], alice), (err) => err.code === 'citation.retrieved_at_required');
++ await assert.rejects(async () => await h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/no-date' }], alice), (err) => err.code === 'citation.retrieved_at_required');
// Publish r2, then diff r1 → r2
-- h.svc.publish(page.id, { revision: 2 }, alice);
-- const d = h.svc.diff(h.svc.pageById(page.id), 1, 2);
++ await h.svc.publish(page.id, { revision: 2 }, alice);
++ const d = await h.svc.diff(await h.svc.pageById(page.id), 1, 2);
assert.ok(d.content.ops.some((o) => o.op === 'insert' && /different/.test(o.text)));
assert.ok(d.fields.some((f) => f.field === 'infobox'));
// Revert to r1 = r3, a copy of r1 with r1's citations carried forward; history is untouched.
-- const rv = h.svc.revert(page.id, { toRevision: 1, expectedRevision: 2, message: 'back to rye' }, alice);
++ const rv = await h.svc.revert(page.id, { toRevision: 1, expectedRevision: 2, message: 'back to rye' }, alice);
assert.strictEqual(rv.revision.number, 3);
assert.strictEqual(rv.revision.kind, 'revert');
assert.strictEqual(rv.revision.revertedTo, 1);
-- assert.strictEqual(rv.revision.content, h.stores.revisions.get(page.id, 1).content);
++ assert.strictEqual(rv.revision.content, (await h.stores.revisions.get(page.id, 1)).content);
assert.strictEqual(rv.published, true, 'a revert of a published page is published');
-- const c3 = h.svc.citationsOf(page, 3);
++ const c3 = await h.svc.citationsOf(page, 3);
assert.deepStrictEqual(c3.map((c) => c.url), ['https://example.org/rye']);
assert.strictEqual(c3[0].carriedFrom, c1[0].id);
-- assert.strictEqual(h.svc.citationsOf(page, 2).length, 2, 'r2 keeps its own citations');
-- assert.deepStrictEqual(h.svc.view(space, h.svc.pageById(page.id), alice, {}).infobox.map((x) => x.key), ['flour', 'hydration', 'sourdough']);
++ assert.strictEqual((await h.svc.citationsOf(page, 2)).length, 2, 'r2 keeps its own citations');
++ assert.deepStrictEqual((await h.svc.view(space, await h.svc.pageById(page.id), alice, {})).infobox.map((x) => x.key), ['flour', 'hydration', 'sourdough']);
// Lineage: r3 → r2 → r1 by parent pointers.
-- assert.deepStrictEqual(h.stores.revisions.lineage(page.id).map((r) => r.number), [3, 2, 1]);
++ assert.deepStrictEqual((await h.stores.revisions.lineage(page.id)).map((r) => r.number), [3, 2, 1]);
// Immutability is enforced by the database, not by convention.
-- assert.throws(() => h.db.prepare('UPDATE wiki_page_revisions SET content = ? WHERE entity_id = ?').run('rewritten', page.id), /immutable/);
-- assert.throws(() => h.db.prepare('DELETE FROM wiki_page_revisions WHERE entity_id = ?').run(page.id), /never deleted/);
-- assert.throws(() => h.db.prepare('UPDATE wiki_citations SET url = ? WHERE entity_id = ?').run('https://evil.example', page.id), /immutable/);
-- assert.throws(() => h.db.prepare('UPDATE wiki_infobox_values SET value_text = ? WHERE page_id = ?').run('x', page.id), /immutable/);
-- assert.throws(() => h.db.prepare('DELETE FROM wiki_infobox_values WHERE page_id = ?').run(page.id), /never deleted/);
++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_page_revisions SET content = ? WHERE entity_id = ?').run('rewritten', page.id), /immutable/);
++ await assert.rejects(async () => await h.db.prepare('DELETE FROM wiki_page_revisions WHERE entity_id = ?').run(page.id), /never deleted/);
++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_citations SET url = ? WHERE entity_id = ?').run('https://evil.example', page.id), /immutable/);
++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_infobox_values SET value_text = ? WHERE page_id = ?').run('x', page.id), /immutable/);
++ await assert.rejects(async () => await h.db.prepare('DELETE FROM wiki_infobox_values WHERE page_id = ?').run(page.id), /never deleted/);
// The same flow over HTTP: history lists every revision, the diff page renders.
const hist = await H.req(h, 'GET', `/w/bread/rye-bread/history`);
@@@ -108,7 +108,7 @@ const H = require('./helpers');
assert.ok(old.text.includes('content="noindex, nofollow"'));
// Every revision produced a wiki.revision.created event.
-- const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && e.subject.id === page.id).map((e) => e.subject.revision);
++ const created = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.revision.created' && e.subject.id === page.id).map((e) => e.subject.revision);
assert.deepStrictEqual(created, [1, 2, 3]);
console.log('revisions ok');
} finally {
ddiff --git a/test/schedule.test.js b/test/schedule.test.js
iindex 2c21c69..b1a8e07 100644
---- a/test/schedule.test.js
++++ b/test/schedule.test.js
@@@ -12,18 +12,18 @@ const H = require('./helpers');
let clock = Date.parse('2026-10-01T08:00:00Z');
const now = () => clock;
const h1 = await H.boot({ now });
-- const dbPath = h1.dbPath;
++ const shared = h1.db;
const editor = { kind: 'user', subject: H.subject(), staff: false };
-- const space = h1.svc.createSpace({ name: 'Diary', slug: 'diary' }, editor);
-- const { page } = h1.svc.createPage(space.id, { title: 'Launch notes', body: H.LONG, citations: [{ url: 'https://example.org/x', retrievedAt: '2026-09-30T00:00:00Z' }] }, editor);
++ const space = await h1.svc.createSpace({ name: 'Diary', slug: 'diary' }, editor);
++ const { page } = await h1.svc.createPage(space.id, { title: 'Launch notes', body: H.LONG, citations: [{ url: 'https://example.org/x', retrievedAt: '2026-09-30T00:00:00Z' }] }, editor);
const runAt = '2026-10-01T09:00:00Z';
-- const a = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
-- const b = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
++ const a = await h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
++ const b = await h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
assert.strictEqual(a.created, true);
assert.strictEqual(b.created, false, 'the same schedule twice is one job');
assert.strictEqual(a.job.id, b.job.id);
-- assert.strictEqual(h1.svc.pageById(page.id).state, 'scheduled');
++ assert.strictEqual((await h1.svc.pageById(page.id)).state, 'scheduled');
assert.strictEqual((await H.req(h1, 'GET', '/w/diary/launch-notes')).status, 404, 'scheduled is not public yet');
// Not due yet: nothing happens.
@@@ -32,12 +32,12 @@ const H = require('./helpers');
// Due: worker w1 claims it and "crashes" before completing (lease taken, never completed).
clock = Date.parse('2026-10-01T09:00:01Z');
-- const claimed = h1.stores.scheduler.claim({ worker: 'w1' });
++ const claimed = await h1.stores.scheduler.claim({ worker: 'w1' });
assert.strictEqual(claimed.length, 1);
-- await h1.stop();
++ await h1.stop({ keepDb: true });
// A new process on the same database. Inside the lease nothing runs twice.
-- const h2 = await H.boot({ now, dbPath });
++ const h2 = await H.boot({ now, db: shared });
out = await h2.svc.runSchedule({ worker: 'w2' });
assert.strictEqual(out.done.length, 0, 'the lease still belongs to w1');
// After the lease expires the new worker publishes.
@@@ -45,41 +45,42 @@ const H = require('./helpers');
out = await h2.svc.runSchedule({ worker: 'w2' });
assert.strictEqual(out.done.length, 1);
assert.deepStrictEqual(out.done[0].result, { published: 1 });
-- let p = h2.svc.pageById(page.id);
++ let p = await h2.svc.pageById(page.id);
assert.strictEqual(p.state, 'published');
assert.strictEqual(p.published_revision, 1);
assert.strictEqual(p.published_at, clock);
const publishedAt = p.published_at;
assert.strictEqual((await H.req(h2, 'GET', '/w/diary/launch-notes')).status, 200);
-- const count = (h) => {
-- const all = H.outbox(h).filter((e) => e.subject.id === page.id);
++ const count = async (h) => {
++ const all = (await H.outbox(h)).filter((e) => e.subject.id === page.id);
return { published: all.filter((e) => e.event_type === 'wiki.page.published').length, index: all.filter((e) => e.event_type === 'wiki.index_document.upserted').length };
};
-- const once = count(h2);
++ const once = (await count(h2));
assert.deepStrictEqual(once, { published: 1, index: 1 });
// Re-running the same job (a replay after a crash between publish and complete) is a no-op.
const replay = await h2.stores.scheduler.runDue({ worker: 'w3', handler: async () => ({}) }); // nothing due any more
assert.strictEqual(replay.done.length, 0);
-- h2.stores.scheduler.schedule({ entityId: page.id, action: 'publish', runAt, revision: 1, key: 'replay-of-the-same-job' });
++ await h2.stores.scheduler.schedule({ entityId: page.id, action: 'publish', runAt, revision: 1, key: 'replay-of-the-same-job' });
clock += 1000;
out = await h2.svc.runSchedule({ worker: 'w3' });
assert.deepStrictEqual(out.done[0].result, { noop: true });
-- assert.deepStrictEqual(count(h2), once, 'no second publication event and no new index revision');
-- p = h2.svc.pageById(page.id);
++ assert.deepStrictEqual((await count(h2)), once, 'no second publication event and no new index revision');
++ p = await h2.svc.pageById(page.id);
assert.strictEqual(p.published_at, publishedAt, 'the first publication time is kept');
-- await h2.stop();
++ await h2.stop({ keepDb: true });
// A third restart: still exactly one publication.
-- const h3 = await H.boot({ now, dbPath });
++ const h3 = await H.boot({ now, db: shared });
out = await h3.svc.runSchedule({ worker: 'w4' });
assert.strictEqual(out.done.length, 0);
-- assert.deepStrictEqual(count(h3), once);
++ assert.deepStrictEqual((await count(h3)), once);
// Deleting a page cancels its pending jobs.
-- h3.svc.schedulePublish(page.id, { revision: 1, runAt: '2026-12-01T00:00:00Z' }, editor);
-- h3.svc.deletePage(page.id, { kind: 'user', subject: editor.subject });
-- assert.ok(h3.svc.jobs(page.id).filter((j) => j.status === 'pending').length === 0);
++ await h3.svc.schedulePublish(page.id, { revision: 1, runAt: '2026-12-01T00:00:00Z' }, editor);
++ await h3.svc.deletePage(page.id, { kind: 'user', subject: editor.subject });
++ assert.ok((await h3.svc.jobs(page.id)).filter((j) => j.status === 'pending').length === 0);
await h3.stop();
++ await h1.closeDb();
console.log('schedule ok');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 06a9557..4be109a 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -23,22 +23,22 @@ const H = require('./helpers');
const ann = { kind: 'user', subject: H.subject(), staff: false };
const bob = { kind: 'user', subject: H.subject(), staff: false };
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
-- const annSpace = h.svc.createSpace({ name: 'Ann Space', slug: 'ann-space' }, ann);
-- const annPage = h.svc.createPage(annSpace.id, { title: 'Ann Page', body: H.LONG, citations: cite }, ann).page;
-- h.svc.publish(annPage.id, {}, ann);
-- const annPrivate = h.svc.createPage(annSpace.id, { title: 'Ann Private', body: H.LONG, citations: cite, visibility: 'private' }, ann).page;
-- const bobSpace = h.svc.createSpace({ name: 'Bob Space', slug: 'bob-space' }, bob);
-- const bobPage = h.svc.createPage(bobSpace.id, { title: 'Bob Page', body: H.LONG, citations: cite }, bob).page;
-- h.svc.publish(bobPage.id, {}, bob);
-- const proposal = h.svc.propose({ space: annSpace.id, pageId: annPage.id, title: 'Ann Page', body: `${H.LONG} proposed`, summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
++ const annSpace = await h.svc.createSpace({ name: 'Ann Space', slug: 'ann-space' }, ann);
++ const annPage = (await h.svc.createPage(annSpace.id, { title: 'Ann Page', body: H.LONG, citations: cite }, ann)).page;
++ await h.svc.publish(annPage.id, {}, ann);
++ const annPrivate = (await h.svc.createPage(annSpace.id, { title: 'Ann Private', body: H.LONG, citations: cite, visibility: 'private' }, ann)).page;
++ const bobSpace = await h.svc.createSpace({ name: 'Bob Space', slug: 'bob-space' }, bob);
++ const bobPage = (await h.svc.createPage(bobSpace.id, { title: 'Bob Page', body: H.LONG, citations: cite }, bob)).page;
++ await h.svc.publish(bobPage.id, {}, bob);
++ const proposal = await h.svc.propose({ space: annSpace.id, pageId: annPage.id, title: 'Ann Page', body: `${H.LONG} proposed`, summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
const proposalId = proposal.id || (proposal.proposal && proposal.proposal.id);
const bobTok = H.userToken({ subject: bob.subject, username: 'bob' });
const annTok = H.userToken({ subject: ann.subject, username: 'ann' });
-- const tables = h.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'wiki_%' AND name NOT IN ('wiki_event_outbox', 'wiki_module_dirty', 'wiki_module_pushes')").all().map((r) => r.name);
-- const snapshot = () => Object.fromEntries(tables.map((t) => [t, h.db.prepare(`SELECT * FROM ${t}`).all()]));
-- const same = (before, label) => {
-- const after = snapshot();
++ const tables = (await h.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE 'wiki_%' AND table_name NOT IN ('wiki_event_outbox', 'wiki_module_dirty', 'wiki_module_pushes')").all()).map((r) => r.name);
++ const snapshot = async () => Object.fromEntries(await Promise.all(tables.map(async (t) => [t, await h.db.prepare(`SELECT * FROM ${t}`).all()])));
++ const same = async (before, label) => {
++ const after = await snapshot();
for (const t of tables) assert.deepStrictEqual(after[t], before[t], `${label}: ${t} changed`);
};
const api = (method, p, body, token = bobTok) => H.req(h, method, `/api/v1${p}`, { token, body });
@@@ -46,7 +46,7 @@ const H = require('./helpers');
const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status) || (r.status === 303 && !/\/w\/ann-space/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
await check('API: Bob, owner of his own space, cannot touch Ann\'s page or space by id', async () => {
-- const before = snapshot();
++ const before = await snapshot();
for (const id of [annPage.id, annPrivate.id]) {
refused(await api('PATCH', `/pages/${id}`, { title: 'pwned', slug: 'pwned', visibility: 'public', noindex: true }), `PATCH ${id}`);
refused(await api('DELETE', `/pages/${id}`), `DELETE ${id}`);
@@@ -68,11 +68,11 @@ const H = require('./helpers');
refused(await api('POST', `/proposals/${proposalId}/review`, { decision: 'approve' }), 'review Ann\'s proposal');
refused(await api('GET', `/pages/${annPrivate.id}`), 'read the private page');
refused(await api('GET', `/proposals/${proposalId}`), 'read Ann\'s proposal');
-- same(before, 'API');
++ await same(before, 'API');
});
await check('forms: Bob cannot post to Ann\'s pages, nor reach them through his own space\'s slug', async () => {
-- const before = snapshot();
++ const before = await snapshot();
for (const sp of ['ann-space', 'bob-space']) {
for (const slug of ['ann-page', 'ann-private']) {
refused(await form(`/w/${sp}/${slug}/edit`, { body: `${H.LONG} pwned`, summary: 'x', expected_revision: '1' }), `edit ${sp}/${slug}`);
@@@ -85,7 +85,7 @@ const H = require('./helpers');
refused(await form('/s/ann-space/new', { title: 'Bob in Ann', body: H.LONG }), 'new page in Ann\'s space');
refused(await form('/s/ann-space/import', { bundle: '{}' }), 'import form');
refused(await form(`/proposals/${proposalId}`, { decision: 'approve' }), 'proposal form');
-- same(before, 'forms');
++ await same(before, 'forms');
const r = await H.req(h, 'GET', '/w/bob-space/ann-private', { cookie: H.cookieFor(bobTok) });
assert.strictEqual(r.status, 404, 'Ann\'s slug under Bob\'s space is not her page');
});
@@@ -94,9 +94,9 @@ const H = require('./helpers');
assert.strictEqual((await api('PATCH', '/spaces/ann-space', { name: 'Ann Space 2' }, annTok)).status, 200);
const f = await form('/s/ann-space/settings', { name: 'Ann Space 3', visibility: 'public' }, annTok);
assert.ok([200, 303].includes(f.status), `the settings form works for its owner: ${f.status} ${f.text.slice(0, 160)}`);
-- assert.strictEqual(h.db.prepare("SELECT name FROM wiki_spaces WHERE slug = 'ann-space'").get().name, 'Ann Space 3');
++ assert.strictEqual((await h.db.prepare("SELECT name FROM wiki_spaces WHERE slug = 'ann-space'").get()).name, 'Ann Space 3');
assert.strictEqual((await api('GET', `/pages/${annPrivate.id}`, undefined, annTok)).status, 200);
-- const cur = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(annPage.id).n;
++ const cur = (await h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(annPage.id)).n;
const r = await api('POST', `/pages/${annPage.id}/revisions`, { expected_revision: cur, body: `${H.LONG} by ann`, citations: cite }, annTok);
assert.ok([200, 201].includes(r.status), `${r.status} ${r.text.slice(0, 200)}`);
});
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex c988d8c..e597925 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -33,23 +33,23 @@ const { getPaths, crawl } = require('./security-crawl');
let failures = 0;
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
try {
-- const vault = h.svc.createSpace({ name: SECRET.spaceName, slug: 'vault', visibility: 'private' }, owner);
-- h.svc.setRole(vault.id, friend, 'viewer', owner);
-- const hidden = h.svc.createPage(vault.id, { title: SECRET.hiddenTitle, body: body(SECRET.hiddenWords), citations: cite }, owner).page;
-- h.svc.publish(hidden.id, {}, owner);
++ const vault = await h.svc.createSpace({ name: SECRET.spaceName, slug: 'vault', visibility: 'private' }, owner);
++ await h.svc.setRole(vault.id, friend, 'viewer', owner);
++ const hidden = (await h.svc.createPage(vault.id, { title: SECRET.hiddenTitle, body: body(SECRET.hiddenWords), citations: cite }, owner)).page;
++ await h.svc.publish(hidden.id, {}, owner);
-- const open = h.svc.createSpace({ name: 'Open Space', slug: 'open' }, owner);
-- const control = h.svc.createPage(open.id, { title: 'Open Page', body: body('open-control-words'), citations: cite }, owner).page;
-- h.svc.publish(control.id, {}, owner);
++ const open = await h.svc.createSpace({ name: 'Open Space', slug: 'open' }, owner);
++ const control = (await h.svc.createPage(open.id, { title: 'Open Page', body: body('open-control-words'), citations: cite }, owner)).page;
++ await h.svc.publish(control.id, {}, owner);
// Restricted from the start (a page that was public once was legitimately public then).
-- const priv = h.svc.createPage(open.id, { title: SECRET.privateTitle, body: body(SECRET.privateWords), citations: cite, visibility: 'private' }, owner).page;
-- h.svc.publish(priv.id, {}, owner);
-- const members = h.svc.createPage(open.id, { title: 'Members Page', body: body(SECRET.membersWords), citations: cite, visibility: 'members' }, owner).page;
-- h.svc.publish(members.id, {}, owner);
-- const draft = h.svc.createPage(open.id, { title: SECRET.draftTitle, body: body(SECRET.draftWords), citations: cite }, owner).page;
++ const priv = (await h.svc.createPage(open.id, { title: SECRET.privateTitle, body: body(SECRET.privateWords), citations: cite, visibility: 'private' }, owner)).page;
++ await h.svc.publish(priv.id, {}, owner);
++ const members = (await h.svc.createPage(open.id, { title: 'Members Page', body: body(SECRET.membersWords), citations: cite, visibility: 'members' }, owner)).page;
++ await h.svc.publish(members.id, {}, owner);
++ const draft = (await h.svc.createPage(open.id, { title: SECRET.draftTitle, body: body(SECRET.draftWords), citations: cite }, owner)).page;
// A published page with a second, unpublished revision.
-- h.svc.editPage(control.id, { expectedRevision: 1, body: body(SECRET.rev2Words), summary: 'wip', citations: cite }, owner);
-- const proposal = h.svc.propose({ space: vault.id, pageId: hidden.id, title: SECRET.hiddenTitle, body: body(SECRET.proposalWords), summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
++ await h.svc.editPage(control.id, { expectedRevision: 1, body: body(SECRET.rev2Words), summary: 'wip', citations: cite }, owner);
++ const proposal = await h.svc.propose({ space: vault.id, pageId: hidden.id, title: SECRET.hiddenTitle, body: body(SECRET.proposalWords), summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
const pageIds = [hidden.id, priv.id, draft.id, members.id, proposal.id || proposal.proposal && proposal.proposal.id];
const ownerTok = H.userToken({ subject: owner.subject, username: 'owner' });
@@@ -91,7 +91,7 @@ const { getPaths, crawl } = require('./security-crawl');
});
await check('public events and Search index documents carry none of it (internal events are first-party only)', async () => {
-- const text = JSON.stringify(H.outbox(h).filter((e) => e.visibility === 'public' || e.event_type === 'wiki.index_document.upserted'));
++ const text = JSON.stringify((await H.outbox(h)).filter((e) => e.visibility === 'public' || e.event_type === 'wiki.index_document.upserted'));
assert.ok(text.includes('open-control-words') || text.includes('Open Page'), 'the public page is there (control)');
const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
assert.deepStrictEqual(hits, []);
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 9eed859..4d5745c 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -37,10 +37,10 @@ const SECRETS = {
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
try {
const owner = { kind: 'user', subject: H.subject(), staff: false };
-- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
++ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
-- const page = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;
-- h.svc.publish(page.id, {}, owner);
++ const page = (await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner)).page;
++ await h.svc.publish(page.id, {}, owner);
const people = {
anonymous: null,
user: H.userToken({ subject: H.subject(), username: 'someone' }),
@@@ -89,7 +89,7 @@ const SECRETS = {
});
await check('the events outbox carries neither', async () => {
-- const text = JSON.stringify(H.outbox(h));
++ const text = JSON.stringify(await H.outbox(h));
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
});
} finally {
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex bd95370..02dec0e 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -34,19 +34,19 @@ const INTERNAL = [
try {
const owner = { kind: 'user', subject: H.subject(), staff: false };
const tok = H.userToken({ subject: owner.subject, username: 'owner' });
-- const space = h.svc.createSpace({ name: 'Refs', slug: 'refs' }, owner);
++ const space = await h.svc.createSpace({ name: 'Refs', slug: 'refs' }, owner);
const okCite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
-- const page = h.svc.createPage(space.id, { title: 'Cited Page', body: `${H.LONG} ${INTERNAL.map((u) => `[x](${u})`).join(' ')}`, citations: okCite }, owner).page;
-- h.svc.publish(page.id, {}, owner);
++ const page = (await h.svc.createPage(space.id, { title: 'Cited Page', body: `${H.LONG} ${INTERNAL.map((u) => `[x](${u})`).join(' ')}`, citations: okCite }, owner)).page;
++ await h.svc.publish(page.id, {}, owner);
await check('internal citation URLs are refused or stored, never fetched, through every path that handles them', async () => {
for (const url of INTERNAL) {
// Through the API (attach to a revision), the service (a new revision) and the edit form.
await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/1/citations`, { token: tok, body: { citations: [{ url, retrieved_at: '2026-09-20T00:00:00Z' }] } });
-- try { h.svc.editPage(page.id, { expectedRevision: h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n, body: `${H.LONG} see ${url}`, summary: 'cite', citations: [{ url, retrievedAt: '2026-09-20T00:00:00Z' }] }, owner); } catch { /* refused: fine */ }
++ try { await h.svc.editPage(page.id, { expectedRevision: h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n, body: `${H.LONG} see ${url}`, summary: 'cite', citations: [{ url, retrievedAt: '2026-09-20T00:00:00Z' }] }, owner); } catch { /* refused: fine */ }
}
-- const n = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;
-- try { h.svc.publish(page.id, { revision: n }, owner); } catch { /* the gate may refuse: fine */ }
++ const n = (await h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id)).n;
++ try { await h.svc.publish(page.id, { revision: n }, owner); } catch { /* the gate may refuse: fine */ }
for (const p of ['/w/refs/cited-page', '/w/refs/cited-page/sources', '/w/refs/cited-page/history', `/api/v1/pages/${page.id}`, `/api/v1/pages/${page.id}/revisions/${n}/citations`,
`/api/v1/pages/${page.id}/revisions/1/citations`, '/feed.atom', '/feed.json', '/llms.txt', '/sitemaps/pages-1.xml']) {
await H.req(h, 'GET', p, { cookie: H.cookieFor(tok) });
ddiff --git a/test/security.test.js b/test/security.test.js
iindex e9d2764..add1bbc 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -11,15 +11,15 @@ const H = require('./helpers');
const owner = { kind: 'user', subject: H.subject(), staff: false };
const mallory = { kind: 'user', subject: H.subject(), staff: false };
try {
-- const space = h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, owner);
-- const { page } = h.svc.createPage(space.id, { title: 'Onboarding', body: H.LONG }, owner);
-- h.svc.publish(page.id, {}, owner);
-- h.svc.updateSpace(space.id, { slug: 'guide' }, owner);
++ const space = await h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, owner);
++ const { page } = await h.svc.createPage(space.id, { title: 'Onboarding', body: H.LONG }, owner);
++ await h.svc.publish(page.id, {}, owner);
++ await h.svc.updateSpace(space.id, { slug: 'guide' }, owner);
// Another person cannot take the retired slug, by creating a space or by renaming theirs.
-- assert.throws(() => h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
-- const own = h.svc.createSpace({ name: 'Mine', slug: 'mine' }, mallory);
-- assert.throws(() => h.svc.updateSpace(own.id, { slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
++ await assert.rejects(async () => await h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
++ const own = await h.svc.createSpace({ name: 'Mine', slug: 'mine' }, mallory);
++ await assert.rejects(async () => await h.svc.updateSpace(own.id, { slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
const r = await H.req(h, 'POST', '/api/v1/spaces', { token: H.userToken({ subject: mallory.subject }), body: { name: 'Handbook', slug: 'handbook' } });
assert.strictEqual(r.status, 409);
@@@ -28,7 +28,7 @@ const H = require('./helpers');
assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).headers.get('location'), '/w/guide/onboarding');
// The space itself may take its old slug back.
-- h.svc.updateSpace(space.id, { slug: 'handbook' }, owner);
++ await h.svc.updateSpace(space.id, { slug: 'handbook' }, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).status, 200);
console.log('security: ok');
} finally {
ddiff --git a/test/seed.test.js b/test/seed.test.js
iindex 68165b7..6246fc0 100644
---- a/test/seed.test.js
++++ b/test/seed.test.js
@@@ -35,15 +35,15 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
try {
-- const first = seed(h.svc, data, { log: H.quiet });
++ const first = await seed(h.svc, data, { log: H.quiet });
assert.strictEqual(first.created.length, data.pages.length);
-- const again = seed(h.svc, data, { log: H.quiet });
++ const again = await seed(h.svc, data, { log: H.quiet });
assert.strictEqual(again.created.length, 0, 'idempotent');
assert.strictEqual(again.skipped.length, data.pages.length);
-- const space = h.svc.findSpace('openvibe');
++ const space = await h.svc.findSpace('openvibe');
assert.strictEqual(space.kind, 'official');
-- const published = h.svc.publishedPublic();
++ const published = await h.svc.publishedPublic();
assert.strictEqual(published.length, data.pages.length);
// Generated (AI-assisted) text: published and readable, but noindex until a person reviews it.
for (const { page, decision } of published) {
@@@ -51,7 +51,7 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.strictEqual(decision.indexable, false, page.title);
assert.strictEqual(decision.listable, false, page.title);
assert.deepStrictEqual(decision.codes, ['ai_generated_unreviewed'], `${page.title}: only the review is missing (${decision.codes.join(', ')})`);
-- assert.strictEqual(h.stores.revisions.get(page.id, page.published_revision).meta.authorship.importedFrom.aiAssisted, true);
++ assert.strictEqual((await h.stores.revisions.get(page.id, page.published_revision)).meta.authorship.importedFrom.aiAssisted, true);
}
for (const p of data.pages) {
const r = await H.req(h, 'GET', `/w/openvibe/${content.pageSlug(p.title)}`);
@@@ -67,17 +67,17 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.strictEqual(atomFeed.status, 200, 'an empty feed, not a 404');
assert.ok(!atomFeed.text.includes('<entry>'));
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
-- const idx = H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.'));
++ const idx = (await H.outbox(h)).filter((e) => e.event_type.startsWith('wiki.index_document.'));
assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');
// The official space is not editable by an ordinary signed-in person, but is by staff.
const person = { kind: 'user', subject: H.subject(), staff: false };
const staff = { kind: 'user', subject: H.subject(), staff: true };
-- assert.throws(() => h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);
-- assert.ok(h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff).page);
++ await assert.rejects(async () => await h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);
++ assert.ok((await h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff)).page);
// After a person's review the page is indexable; its Search document records the imported authorship.
const first1 = published[0].page;
-- h.svc.reviewRevision('openvibe', first1.slug, first1.published_revision, { decision: 'approved' }, staff);
-- const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted').pop().payload;
++ await h.svc.reviewRevision('openvibe', first1.slug, first1.published_revision, { decision: 'approved' }, staff);
++ const doc = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.index_document.upserted').pop().payload;
assert.strictEqual(doc.id, first1.id);
assert.strictEqual(doc.authorship, 'imported');
assert.strictEqual(doc.indexability.decision, 'index');
ddiff --git a/test/threat-review.test.js b/test/threat-review.test.js
iindex fc0c308..d832a9d 100644
---- a/test/threat-review.test.js
++++ b/test/threat-review.test.js
@@@ -23,9 +23,9 @@ const { SYSTEM } = require('../server/wiki/seed');
const anchors = (html, host) => html.match(new RegExp(`<a href="https://${host.replace('.', '\\.')}[^"]*"[^>]*>`, 'g')) || [];
try {
// Community space: every outbound link is nofollow ugc.
-- const user = h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });
-- const up = h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me }).page;
-- h.svc.publish(up.id, {}, { kind: 'user', subject: me });
++ const user = await h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });
++ const up = (await h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me })).page;
++ await h.svc.publish(up.id, {}, { kind: 'user', subject: me });
let html = (await H.req(h, 'GET', '/w/community/links')).text;
const userLinks = [...anchors(html, 'spam.example'), ...anchors(html, 'cited.example'), ...anchors(html, 'infobox.example')];
assert.strictEqual(userLinks.length, 4, userLinks.join('\n'));
@@@ -37,9 +37,9 @@ const { SYSTEM } = require('../server/wiki/seed');
assert.ok(anchors(html, 'spam.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));
// Official space: staff vouch for their links.
-- const off = h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);
-- const op = h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true }).page;
-- h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });
++ const off = await h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);
++ const op = (await h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true })).page;
++ await h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });
html = (await H.req(h, 'GET', '/w/official/links')).text;
assert.ok(anchors(html, 'spam.example').every((a) => /rel="noopener"/.test(a)) && anchors(html, 'spam.example').length === 2);
assert.ok(anchors(html, 'cited.example').every((a) => /rel="noopener nofollow"/.test(a)));
@@@ -53,7 +53,7 @@ const { SYSTEM } = require('../server/wiki/seed');
}
let r = await H.req(h, 'POST', `/api/v1/pages/${up.id}/revisions`, { token: tok, body: { expected_revision: 1, summary: 'x'.repeat(5000) } });
assert.strictEqual(r.status, 422);
-- assert.throws(() => h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');
++ await assert.rejects(async () => await h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');
r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: 'Summarised', body: H.LONG, summary: ' One short sentence. ' } });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.json.revision.summary, 'One short sentence.');
@@@ -66,7 +66,7 @@ const { SYSTEM } = require('../server/wiki/seed');
assert.strictEqual(r.status, 403, `${actorType}: ${r.text}`);
assert.strictEqual(r.json.code, 'proposal.service_only');
}
-- assert.strictEqual(h.svc.findPage(user.id, 'planted'), null);
++ assert.strictEqual(await h.svc.findPage(user.id, 'planted'), null);
r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose'] }), body: proposal });
assert.strictEqual(r.status, 201, r.text);
await h.stop();
@@@ -78,10 +78,10 @@ const { SYSTEM } = require('../server/wiki/seed');
assert.strictEqual((await H.req(limited, 'GET', '/search?q=again')).status, 429);
assert.strictEqual((await H.req(limited, 'GET', '/')).status, 200);
const owner = { kind: 'user', subject: H.subject() };
-- const sp = limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);
-- const pg = limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner).page;
-- limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);
-- limited.svc.publish(pg.id, {}, owner);
++ const sp = await limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);
++ const pg = (await limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner)).page;
++ await limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);
++ await limited.svc.publish(pg.id, {}, owner);
for (let i = 0; i < 15; i++) {
assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2')).status, 200);
assert.strictEqual((await H.req(limited, 'GET', `/api/v1/pages/${pg.id}/diff?from=1&to=2`)).status, 200);
ddiff --git a/test/vip-gate.test.js b/test/vip-gate.test.js
iindex 4f254ec..c3143ab 100644
---- a/test/vip-gate.test.js
++++ b/test/vip-gate.test.js
@@@ -13,27 +13,16 @@ const STRANGER = H.subject();
const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
(async () => {
-- // Migration: a database made before `vip` (the old CHECK) is widened in place; rows stay; idempotent.
++ // The schema: `vip` is a visibility PostgreSQL accepts, and anything else is still refused by the CHECK
++ // (on SQLite a database made before `vip` was widened in place; migrations/0001 has it from the start).
{
-- const fs = require('fs');
-- const os = require('os');
-- const path = require('path');
-- const { openDb, createStores, widenVisibilityChecks, SCHEMA } = require('../server/db');
-- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-vipmig-')), 'wiki.db');
-- const old = openDb(file);
-- old.exec(SCHEMA.split("CHECK (visibility IN ('public','members','vip','private'))").join("CHECK (visibility IN ('public','members','private'))"));
-- old.prepare("INSERT INTO wiki_spaces (id, slug, name, kind, visibility, owner, created_by, created_at, updated_at) VALUES ('spc_1', 'old', 'Old', 'user', 'members', ?, ?, 1, 1)").run(OWNER, OWNER);
-- assert.throws(() => old.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run(), /CHECK constraint/);
-- old.close();
-- const db = openDb(file);
-- createStores(db);
-- db.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run();
-- assert.strictEqual(db.prepare("SELECT visibility FROM wiki_spaces WHERE id = 'spc_1'").get().visibility, 'vip', 'widened; the row kept');
-- assert.throws(() => db.prepare("UPDATE wiki_spaces SET visibility = 'secret' WHERE id = 'spc_1'").run(), /CHECK constraint/, 'still checked');
-- assert.deepStrictEqual(widenVisibilityChecks(db), [], 'idempotent');
-- assert.strictEqual(db.pragma('integrity_check', { simple: true }), 'ok');
-- db.close();
-- fs.rmSync(path.dirname(file), { recursive: true, force: true });
++ const { testDb } = require('./db-helper');
++ const { db, close } = await testDb();
++ await db.prepare("INSERT INTO wiki_spaces (id, slug, name, kind, visibility, owner, created_by, created_at, updated_at) VALUES ('spc_1', 'old', 'Old', 'user', 'members', ?, ?, 1, 1)").run(OWNER, OWNER);
++ await db.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run();
++ assert.strictEqual((await db.prepare("SELECT visibility FROM wiki_spaces WHERE id = 'spc_1'").get()).visibility, 'vip');
++ await assert.rejects(db.prepare("UPDATE wiki_spaces SET visibility = 'secret' WHERE id = 'spc_1'").run(), /check constraint/i, 'still checked');
++ await close();
}
const asks = [];
@@@ -51,14 +40,14 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
const cookie = (sub) => H.cookieFor(H.userToken({ subject: sub }));
try {
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
-- const club = h.svc.createSpace({ name: 'Members club', slug: 'club', visibility: 'vip' }, owner);
-- const secret = h.svc.createPage(club.id, { title: 'Club secret', body: H.LONG, citations: cite }, owner).page;
-- h.svc.publish(secret.id, {}, owner);
-- const open = h.svc.createSpace({ name: 'Open notes', slug: 'open' }, owner);
-- const perk = h.svc.createPage(open.id, { title: 'Perk page', body: H.LONG, citations: cite, visibility: 'vip' }, owner).page;
-- h.svc.publish(perk.id, {}, owner);
-- const pub = h.svc.createPage(open.id, { title: 'Public page', body: H.LONG, citations: cite }, owner).page;
-- h.svc.publish(pub.id, {}, owner);
++ const club = await h.svc.createSpace({ name: 'Members club', slug: 'club', visibility: 'vip' }, owner);
++ const secret = (await h.svc.createPage(club.id, { title: 'Club secret', body: H.LONG, citations: cite }, owner)).page;
++ await h.svc.publish(secret.id, {}, owner);
++ const open = await h.svc.createSpace({ name: 'Open notes', slug: 'open' }, owner);
++ const perk = (await h.svc.createPage(open.id, { title: 'Perk page', body: H.LONG, citations: cite, visibility: 'vip' }, owner)).page;
++ await h.svc.publish(perk.id, {}, owner);
++ const pub = (await h.svc.createPage(open.id, { title: 'Public page', body: H.LONG, citations: cite }, owner)).page;
++ await h.svc.publish(pub.id, {}, owner);
// Anonymous: the join prompt (403, never publicly cached), no body.
let r = await H.req(h, 'GET', '/w/club/club-secret');
@@@ -118,7 +107,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('club-secret'));
assert.ok(!(await H.req(h, 'GET', '/feed.atom')).text.includes('club-secret'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('perk-page'));
-- const evs = H.outbox(h);
++ const evs = await H.outbox(h);
assert.ok(!evs.some((e) => e.event_type === 'wiki.index_document.upserted' && (e.subject.id === secret.id || e.subject.id === perk.id)), 'no Search document');
assert.ok(evs.some((e) => e.event_type === 'wiki.page.published' && e.subject.id === secret.id && e.payload.visibility === 'vip'));
@@@ -130,7 +119,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
vipDown = false;
// Official spaces have no VIP owner.
-- assert.throws(() => h.svc.createSpace({ name: 'Staff club', slug: 'staff-club', kind: 'official', visibility: 'vip' }, { kind: 'system', service: 'svc:wiki' }), /no VIP owner/);
++ await assert.rejects(async () => await h.svc.createSpace({ name: 'Staff club', slug: 'staff-club', kind: 'official', visibility: 'vip' }, { kind: 'system', service: 'svc:wiki' }), /no VIP owner/);
} finally {
await h.stop();
}
ddiff --git a/test/visibility.test.js b/test/visibility.test.js
iindex 807260e..5809102 100644
---- a/test/visibility.test.js
++++ b/test/visibility.test.js
@@@ -9,33 +9,33 @@ const assert = require('assert');
const contracts = require('openvibe-contracts');
const H = require('./helpers');
--function lastIndexEvent(h, pageId) {
-- return H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === pageId).pop();
++async function lastIndexEvent(h, pageId) {
++ return (await H.outbox(h)).filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === pageId).pop();
}
(async () => {
const h = await H.boot();
const owner = { kind: 'user', subject: H.subject(), staff: false };
try {
-- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
++ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
-- const { page } = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner);
-- const other = h.svc.createPage(space.id, { title: 'Second page', body: H.LONG, citations: cite }, owner).page;
++ const { page } = await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner);
++ const other = (await h.svc.createPage(space.id, { title: 'Second page', body: H.LONG, citations: cite }, owner)).page;
// Draft: not in sitemap, feed or index; direct URL is 404 for anonymous visitors.
let r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
assert.ok(!r.text.includes('/w/notes/open-page'));
assert.strictEqual((await H.req(h, 'GET', '/w/notes/open-page')).status, 404);
-- assert.strictEqual(lastIndexEvent(h, page.id), undefined, 'a draft never reaches Search');
++ assert.strictEqual((await lastIndexEvent(h, page.id)), undefined, 'a draft never reaches Search');
// Published: sitemap, feeds, index upsert, public cache.
-- h.svc.publish(page.id, {}, owner);
-- h.svc.publish(other.id, {}, owner);
++ await h.svc.publish(page.id, {}, owner);
++ await h.svc.publish(other.id, {}, owner);
r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
assert.ok(r.text.includes('http://wiki.test/w/notes/open-page'));
assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/notes/open-page'));
assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/notes/open-page'));
-- let ev = lastIndexEvent(h, page.id);
++ let ev = (await lastIndexEvent(h, page.id));
assert.strictEqual(ev.event_type, 'wiki.index_document.upserted');
assert.strictEqual(ev.payload.visibility, 'public');
assert.strictEqual(ev.payload.indexability.decision, 'index');
@@@ -48,9 +48,9 @@ function lastIndexEvent(h, pageId) {
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
// Private page: leaves sitemap, feeds and Search (tombstone at a higher revision), 404 + private for anonymous.
-- const before = lastIndexEvent(h, page.id).payload.revision;
-- h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
-- ev = lastIndexEvent(h, page.id);
++ const before = (await lastIndexEvent(h, page.id)).payload.revision;
++ await h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
++ ev = (await lastIndexEvent(h, page.id));
assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
assert.ok(ev.payload.revision > before, 'the tombstone outranks the last document');
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('open-page'));
@@@ -70,47 +70,47 @@ function lastIndexEvent(h, pageId) {
// Search (the wiki's own) does not return it to anonymous callers.
assert.ok(!(await H.req(h, 'GET', '/api/v1/search?q=Open')).json.results.some((x) => x.id === page.id));
// The product event for a non-public change is internal.
-- const upd = H.outbox(h).filter((e) => e.event_type === 'wiki.page.updated' && e.subject.id === page.id).pop();
++ const upd = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.page.updated' && e.subject.id === page.id).pop();
assert.strictEqual(upd.visibility, 'internal');
// Space goes members-only: its public pages leave the index too.
-- h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
-- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');
++ await h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
++ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.deleted');
assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 404, 'anonymous cannot read a members page');
r = await H.req(h, 'GET', '/w/notes/second-page', { cookie: H.cookieFor(H.userToken()) });
assert.strictEqual(r.status, 200, 'any signed-in account reads a members page');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.ok(!(await H.req(h, 'GET', '/sitemaps/spaces.xml')).text.includes('/s/notes'));
-- h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
-- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.upserted', 'back in the index when public again');
++ await h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
++ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.upserted', 'back in the index when public again');
// Rename: every old address 301s to the current one (chains collapse).
-- h.svc.movePage(other.id, { slug: 'second' }, owner);
-- h.svc.movePage(other.id, { slug: 'third' }, owner);
++ await h.svc.movePage(other.id, { slug: 'second' }, owner);
++ await h.svc.movePage(other.id, { slug: 'third' }, owner);
r = await H.req(h, 'GET', '/w/notes/second-page/history');
assert.strictEqual(r.status, 301);
assert.strictEqual(r.headers.get('location'), '/w/notes/third/history');
r = await H.req(h, 'GET', '/w/notes/second');
assert.strictEqual(r.headers.get('location'), '/w/notes/third');
-- ev = lastIndexEvent(h, other.id);
++ ev = (await lastIndexEvent(h, other.id));
assert.strictEqual(ev.payload.canonical_url, 'http://wiki.test/w/notes/third');
// A renamed space redirects its pages as well.
-- h.svc.updateSpace(space.id, { slug: 'jottings' }, owner);
++ await h.svc.updateSpace(space.id, { slug: 'jottings' }, owner);
r = await H.req(h, 'GET', '/w/notes/third');
assert.strictEqual(r.status, 301);
assert.strictEqual(r.headers.get('location'), '/w/jottings/third');
assert.strictEqual((await H.req(h, 'GET', '/s/notes')).headers.get('location'), '/s/jottings');
// Delete: 410 at the current and the old addresses, tombstone, gone from sitemap.
-- h.svc.deletePage(other.id, owner);
++ await h.svc.deletePage(other.id, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/jottings/third')).status, 410);
assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 410);
-- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');
-- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.deleted' && e.subject.id === other.id));
++ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.deleted');
++ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.page.deleted' && e.subject.id === other.id));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('third'));
// Contracts: every envelope and every Search document validates.
-- const all = H.outbox(h);
++ const all = await H.outbox(h);
for (const env of all) {
const v = contracts.validate('events.event-envelope@1', env);
assert.ok(v.valid, `${env.event_type}: ${JSON.stringify(v.errors)}`);