openvibers/openvibe.reviews Commits

Reviews 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 · 756f62e3f2 · 34 files, +1631 −1103

- Schema: migrations/0001_initial.sql (openvibe-sdk tools/asyncify gen-migration: the SQLite schema, evaluated for its
  interpolated CHECK lists; the Publishing revisions, authorship, redirects, discussion and index-hooks tables; the
  SDK inbox and outbox under Reviews' own names). The eight immutability triggers (signals, citations, aggregates,
  audit) are PL/pgSQL, UPDATE OF keeps its column list; the name index is on lower(name).
- server/db.js: openDb(config) (DATABASE_URL through PgBouncer, migrations as the owner, PGlite in development);
  start() takes a migrated handle (the tests). The service's tx helper is an ambient transaction.
- Every read and write is awaited (asyncify plus hand fixes). The aggregate batch awaited nothing and counted depth
  per process: it now keeps its dirty entities per batch in AsyncLocalStorage, so one editorial action still makes at
  most one aggregate revision per entity, and concurrent requests keep their own. Citation state and summary points
  are awaited together; the editor desk reads item views before rendering; revision filters read their rule first.
  Gauges read the database one scrape behind (collect() is synchronous). The events consumer runs on the PostgreSQL
  inbox. Search by EXISTS instead of DISTINCT with ORDER BY.
- Per-actor limits in Valkey; /api/ready reports the store that answered and Valkey.
- scripts/migrate-to-postgres.js (runSqliteMigration); rehearsed on a copy of production: verified.
- Tests on createTestDb (PGlite; npm run test:pg through PgBouncer), one database per boot. Pins:
  openvibe-publishing v1.0.0, openvibe-contracts v0.76.0, openvibe-sdk v0.20.0. README and STATUS.json follow.

npm test 15/15 on PGlite; npm run test:pg 15/15 through PgBouncer.

Changes

ddiff --git a/.env.example b/.env.example
iindex d26e480..5a3a401 100644
---- a/.env.example
++++ b/.env.example
@@@ -19,6 +19,14 @@ NODE_ENV=development
#REVIEWS_LIMITS_HOUR=3000
# Store (SQLite, WAL). Production: /var/lib/openvibe-reviews/reviews.db (systemd StateDirectory).
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh reviews: 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.
REVIEWS_DB_PATH=./data/reviews.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 bc912b6..a714023 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: reviews
++ # 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 c042a1c..6e87632 100644
---- a/README.md
++++ b/README.md
@@@ -18,7 +18,7 @@ it rests on, and nothing a summary says can become a number.
## Owns
--The nine authority tables of §15.13, in Reviews' own SQLite database:
++The nine authority tables of §15.13, in Reviews' own PostgreSQL database (`ov_reviews` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
| Table | What it holds |
|---|---|
@@@ -26,7 +26,7 @@ The nine authority tables of §15.13, in Reviews' own SQLite database:
| `review_entity_aliases` | names, URLs, GTIN/SKU/MPN, source bindings and external ids used for resolution (strong identifiers are unique) |
| `review_sources` | the OpenVibe.Sources sources Reviews has seen: name, homepage, licence and terms notes, health |
| `review_source_items` | Sources items Reviews read — signal fields only, **never review text** — with provenance and how each was resolved |
--| `review_signals` | typed observations (`recommendation`, `recommendation_tally`, `rating`, `rating_aggregate`) from one item revision, with provenance; values immutable (SQLite triggers), status `active`/`superseded`/`withdrawn` |
++| `review_signals` | typed observations (`recommendation`, `recommendation_tally`, `rating`, `rating_aggregate`) from one item revision, with provenance; values immutable (database triggers), status `active`/`superseded`/`withdrawn` |
| `review_summaries` | one editorial summary per entity, its publication state and review flag |
| `review_summary_revisions` | immutable summary revisions (openvibe-publishing/revisions) with authorship |
| `review_trust_metadata` | editor trust decisions per source / signal / entity (exclude from the aggregate with a visible reason, limitation and verification notes), history kept |
@@@ -225,7 +225,8 @@ by editors; signals arrive only from Sources.
- OpenVibe.Community — discussion threads (`community.comment.write`)
- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
- OpenVibe.Search — consumes the index events
--- packages: openvibe-publishing v0.4.0, openvibe-contracts v0.49.0, openvibe-shared v1.25.0, openvibe-sdk v0.12.0
++- 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)
++- packages: openvibe-publishing v1.0.0, openvibe-contracts v0.76.0, openvibe-shared v1.25.0, openvibe-sdk v0.20.0
## Capabilities
@@@ -285,13 +286,16 @@ references and the gaps that remain: [docs/threat-review.md](docs/threat-review.
Production deploys with `sudo ovhost deploy reviews` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.reviews`, install on a lockfile change, restart, wait for `/api/ready`).
--The unit is `openvibe-reviews.service` on `127.0.0.1:4830`, the env file `/etc/openvibe/reviews.env`. The store is
++The unit is `openvibe-reviews.service` on `127.0.0.1:4830`, the env file `/etc/openvibe/reviews.env`. The database is
++`ov_reviews` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh reviews` writes its settings); the
++release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
++`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old
++`/var/lib/openvibe-reviews/reviews.db` stays read-only for 7 days as the rollback. The store is
`/var/lib/openvibe-reviews/reviews.db`; the nginx reference is
[deploy/nginx/openvibe.reviews.conf](deploy/nginx/openvibe.reviews.conf) (the domain still serves the
OpenVibe.Sites placeholder).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
--restart; afterwards `sudo ovhost rollback reviews --to <sha>`. Nothing blocks a rollback: the schema
--code only adds tables and columns.
++restart; afterwards `sudo ovhost rollback reviews --to <sha>`. Migrations only add tables and columns.
## Launch rule
ddiff --git a/STATUS.json b/STATUS.json
iindex 03b12ca..f249a8f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,8 +7,12 @@
"runtime": "node 22, express, better-sqlite3",
"port": 4830,
"domain": "openvibe.reviews",
-- "contracts": "openvibe-contracts v0.49.0",
-- "packages": ["openvibe-publishing v0.4.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
++ "contracts": "openvibe-contracts v0.76.0",
++ "packages": [
++ "openvibe-publishing v1.0.0",
++ "openvibe-shared v1.25.0",
++ "openvibe-sdk v0.20.0"
++ ],
"capabilities": [
"reviews.entity.resolve",
"reviews.entity.manage",
ddiff --git a/deploy/systemd/openvibe-reviews.service b/deploy/systemd/openvibe-reviews.service
iindex 2098320..818984b 100644
---- a/deploy/systemd/openvibe-reviews.service
++++ b/deploy/systemd/openvibe-reviews.service
@@@ -4,7 +4,7 @@ Description=OpenVibe.Reviews — review signals with provenance, entity resoluti
# keeps retrying the JWKS fetch: public pages work, sign-in and token calls answer 503 meanwhile.
# Sources, Events and Community are optional: each degrades to an explicit failure state (without
# Sources there are no signals, so no aggregates — the pages say so).
--After=network.target
++After=network.target postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service openvibe-sources.service
[Service]
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..59cd15f
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,371 @@
++-- phase: expand
++-- OpenVibe.Reviews on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.
++
++CREATE TABLE review_entities (
++ id text COLLATE "C" PRIMARY KEY,
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ name text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('product','game','software','service','place','organization','media','other')),
++ description text COLLATE "C",
++ state text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','deleted')),
++ merged_into text COLLATE "C" REFERENCES review_entities(id),
++ noindex bigint NOT NULL DEFAULT 0,
++ created_by text COLLATE "C" NOT NULL,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ deleted_at bigint,
++ CHECK ((state = 'merged') = (merged_into IS NOT NULL))
++);
++CREATE INDEX review_entities_merged ON review_entities (merged_into);
++CREATE INDEX review_entities_name ON review_entities (lower(name));
++
++CREATE TABLE review_entity_aliases (
++ id text COLLATE "C" PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),
++ type text COLLATE "C" NOT NULL CHECK (type IN ('name','url','sku','gtin','mpn','source','external')),
++ value text COLLATE "C" NOT NULL,
++ norm text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ created_at bigint NOT NULL,
++ removed_at bigint,
++ removed_by text COLLATE "C"
++);
++-- A strong identifier (URL, SKU, GTIN, MPN, source binding, external id) names one entity at a time;
++-- names may be shared (two things called "Portal" is why names need an editor's confirmation).
++CREATE UNIQUE INDEX review_entity_aliases_strong ON review_entity_aliases (type, norm) WHERE removed_at IS NULL AND type != 'name';
++CREATE UNIQUE INDEX review_entity_aliases_name ON review_entity_aliases (norm, entity_id) WHERE removed_at IS NULL AND type = 'name';
++CREATE INDEX review_entity_aliases_entity ON review_entity_aliases (entity_id);
++
++CREATE TABLE review_sources (
++ key text COLLATE "C" PRIMARY KEY,
++ name text COLLATE "C",
++ homepage_url text COLLATE "C",
++ category text COLLATE "C",
++ license_note text COLLATE "C",
++ terms_note text COLLATE "C",
++ status text COLLATE "C",
++ stale bigint,
++ last_success_at text COLLATE "C",
++ first_seen_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++
++CREATE TABLE review_source_items (
++ id text COLLATE "C" PRIMARY KEY,
++ source_key text COLLATE "C" NOT NULL REFERENCES review_sources(key),
++ kind text COLLATE "C" NOT NULL,
++ identity text COLLATE "C" NOT NULL,
++ canonical_url text COLLATE "C",
++ title text COLLATE "C",
++ item_revision bigint NOT NULL,
++ content_hash text COLLATE "C" NOT NULL,
++ published_at text COLLATE "C",
++ retrieved_at text COLLATE "C" NOT NULL,
++ parser_version text COLLATE "C",
++ license_note text COLLATE "C",
++ terms_note text COLLATE "C",
++ fields text COLLATE "C" NOT NULL DEFAULT '{}',
++ state text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (state IN ('active','removed')),
++ removed_at text COLLATE "C",
++ removed_reason text COLLATE "C",
++ resolution text COLLATE "C" NOT NULL DEFAULT 'unmatched' CHECK (resolution IN ('resolved','ambiguous','unmatched','ignored')),
++ entity_id text COLLATE "C" REFERENCES review_entities(id),
++ resolution_rule text COLLATE "C",
++ candidates text COLLATE "C" NOT NULL DEFAULT '[]',
++ resolved_by text COLLATE "C",
++ resolved_at bigint,
++ signal_note text COLLATE "C",
++ first_seen_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ CHECK ((resolution = 'resolved') = (entity_id IS NOT NULL))
++);
++CREATE INDEX review_source_items_resolution ON review_source_items (resolution, updated_at);
++CREATE INDEX review_source_items_entity ON review_source_items (entity_id);
++CREATE INDEX review_source_items_source ON review_source_items (source_key);
++
++CREATE TABLE review_signals (
++ id text COLLATE "C" PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),
++ source_item_id text COLLATE "C" NOT NULL REFERENCES review_source_items(id),
++ source_key text COLLATE "C" NOT NULL REFERENCES review_sources(key),
++ item_revision bigint NOT NULL,
++ type text COLLATE "C" NOT NULL CHECK (type IN ('recommendation','recommendation_tally','rating','rating_aggregate')),
++ recommended bigint CHECK (recommended IN (0,1)),
++ positive_count bigint,
++ total_count bigint,
++ rating_value double precision,
++ rating_best double precision,
++ rating_worst double precision,
++ rating_count bigint,
++ observed_at text COLLATE "C" NOT NULL,
++ source_published_at text COLLATE "C",
++ canonical_url text COLLATE "C",
++ license_note text COLLATE "C",
++ trust text COLLATE "C" NOT NULL DEFAULT '{}',
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','withdrawn')),
++ status_reason text COLLATE "C",
++ status_at bigint,
++ superseded_by text COLLATE "C",
++ created_by text COLLATE "C" NOT NULL,
++ created_at bigint NOT NULL,
++ CHECK (type != 'recommendation' OR recommended IS NOT NULL),
++ CHECK (type != 'recommendation_tally' OR (positive_count IS NOT NULL AND total_count IS NOT NULL AND positive_count >= 0 AND total_count >= positive_count)),
++ CHECK (type NOT IN ('rating','rating_aggregate') OR rating_value IS NOT NULL)
++);
++-- At most one active signal per source item: an update supersedes, a removal withdraws.
++CREATE UNIQUE INDEX review_signals_active_item ON review_signals (source_item_id) WHERE status = 'active';
++CREATE INDEX review_signals_entity ON review_signals (entity_id, status);
++-- Provenance and values never change after the signal exists; only its status moves.
++CREATE FUNCTION review_signals_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_signals values and provenance are immutable'; END $$;
++CREATE TRIGGER review_signals_immutable BEFORE UPDATE OF id, entity_id, source_item_id, source_key, item_revision, type, recommended, positive_count, total_count, rating_value, rating_best, rating_worst, rating_count, observed_at, source_published_at, canonical_url, license_note, trust, created_by, created_at ON review_signals FOR EACH ROW EXECUTE FUNCTION review_signals_immutable();
++CREATE FUNCTION review_signals_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_signals rows are withdrawn, never deleted'; END $$;
++CREATE TRIGGER review_signals_no_delete BEFORE DELETE ON review_signals FOR EACH ROW EXECUTE FUNCTION review_signals_no_delete();
++
++CREATE TABLE review_summaries (
++ id text COLLATE "C" PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL UNIQUE REFERENCES review_entities(id),
++ state text COLLATE "C" NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','published','unpublished')),
++ published_revision bigint,
++ published_at bigint,
++ revision_published_at bigint,
++ flagged bigint NOT NULL DEFAULT 0,
++ flag_reason text COLLATE "C",
++ flagged_at bigint,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++
++CREATE TABLE review_summary_citations (
++ summary_id text COLLATE "C" NOT NULL REFERENCES review_summaries(id),
++ revision bigint NOT NULL,
++ point text COLLATE "C" NOT NULL,
++ signal_id text COLLATE "C" NOT NULL REFERENCES review_signals(id),
++ PRIMARY KEY (summary_id, revision, point, signal_id)
++);
++CREATE INDEX review_summary_citations_signal ON review_summary_citations (signal_id);
++CREATE FUNCTION review_summary_citations_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_summary_citations rows are immutable'; END $$;
++CREATE TRIGGER review_summary_citations_no_update BEFORE UPDATE ON review_summary_citations FOR EACH ROW EXECUTE FUNCTION review_summary_citations_no_update();
++CREATE FUNCTION review_summary_citations_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_summary_citations rows are never deleted'; END $$;
++CREATE TRIGGER review_summary_citations_no_delete BEFORE DELETE ON review_summary_citations FOR EACH ROW EXECUTE FUNCTION review_summary_citations_no_delete();
++
++CREATE TABLE review_trust_metadata (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ scope text COLLATE "C" NOT NULL CHECK (scope IN ('source','signal','entity')),
++ scope_id text COLLATE "C" NOT NULL,
++ key text COLLATE "C" NOT NULL CHECK (key IN ('aggregate','limitation','verification')),
++ value text COLLATE "C" NOT NULL,
++ note text COLLATE "C",
++ set_by text COLLATE "C" NOT NULL,
++ set_at bigint NOT NULL,
++ ended_at bigint,
++ ended_by text COLLATE "C"
++);
++CREATE UNIQUE INDEX review_trust_current ON review_trust_metadata (scope, scope_id, key) WHERE ended_at IS NULL;
++
++CREATE TABLE review_entity_links (
++ id text COLLATE "C" PRIMARY KEY,
++ from_entity text COLLATE "C" NOT NULL REFERENCES review_entities(id),
++ to_entity text COLLATE "C" REFERENCES review_entities(id),
++ type text COLLATE "C" NOT NULL CHECK (type IN ('merged_into','related','edition_of','successor_of','part_of','external_ref')),
++ ref text COLLATE "C",
++ note text COLLATE "C",
++ created_by text COLLATE "C" NOT NULL,
++ created_at bigint NOT NULL,
++ ended_at bigint,
++ ended_by text COLLATE "C",
++ end_note text COLLATE "C",
++ CHECK ((type = 'external_ref') = (ref IS NOT NULL AND to_entity IS NULL))
++);
++CREATE INDEX review_entity_links_from ON review_entity_links (from_entity, type);
++CREATE INDEX review_entity_links_to ON review_entity_links (to_entity, type);
++CREATE UNIQUE INDEX review_entity_links_one_merge ON review_entity_links (from_entity) WHERE type = 'merged_into' AND ended_at IS NULL;
++
++CREATE TABLE review_aggregates (
++ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),
++ revision bigint NOT NULL,
++ computed_at bigint NOT NULL,
++ trigger text COLLATE "C" NOT NULL,
++ inputs_hash text COLLATE "C" NOT NULL,
++ result text COLLATE "C",
++ PRIMARY KEY (entity_id, revision)
++);
++CREATE FUNCTION review_aggregates_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_aggregates rows are immutable'; END $$;
++CREATE TRIGGER review_aggregates_no_update BEFORE UPDATE ON review_aggregates FOR EACH ROW EXECUTE FUNCTION review_aggregates_no_update();
++CREATE FUNCTION review_aggregates_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_aggregates rows are never deleted'; END $$;
++CREATE TRIGGER review_aggregates_no_delete BEFORE DELETE ON review_aggregates FOR EACH ROW EXECUTE FUNCTION review_aggregates_no_delete();
++
++CREATE TABLE review_corrections (
++ id text COLLATE "C" PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL REFERENCES review_entities(id),
++ target_type text COLLATE "C" NOT NULL CHECK (target_type IN ('entity','alias','signal','summary','aggregate')),
++ target_id text COLLATE "C",
++ body text COLLATE "C" NOT NULL,
++ evidence_url text COLLATE "C",
++ submitted_by text COLLATE "C" NOT NULL,
++ via text COLLATE "C",
++ status text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (status IN ('open','accepted','rejected')),
++ resolved_by text COLLATE "C",
++ resolution_note text COLLATE "C",
++ resolved_at bigint,
++ created_at bigint NOT NULL
++);
++CREATE INDEX review_corrections_status ON review_corrections (status, created_at);
++CREATE INDEX review_corrections_entity ON review_corrections (entity_id, status);
++CREATE INDEX review_corrections_by ON review_corrections (submitted_by, created_at);
++
++CREATE TABLE review_audit (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ at bigint NOT NULL,
++ actor text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ entity_id text COLLATE "C",
++ target text COLLATE "C",
++ detail text COLLATE "C" NOT NULL DEFAULT '{}'
++);
++CREATE INDEX review_audit_entity ON review_audit (entity_id, id);
++CREATE FUNCTION review_audit_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_audit rows are immutable'; END $$;
++CREATE TRIGGER review_audit_no_update BEFORE UPDATE ON review_audit FOR EACH ROW EXECUTE FUNCTION review_audit_no_update();
++CREATE FUNCTION review_audit_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'review_audit rows are never deleted'; END $$;
++CREATE TRIGGER review_audit_no_delete BEFORE DELETE ON review_audit FOR EACH ROW EXECUTE FUNCTION review_audit_no_delete();
++
++CREATE TABLE review_import_queue (
++ item_id text COLLATE "C" PRIMARY KEY,
++ reason text COLLATE "C" NOT NULL,
++ enqueued_at bigint NOT NULL,
++ attempts bigint NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ last_error text COLLATE "C"
++);
++
++CREATE TABLE review_sync_state (
++ name text COLLATE "C" PRIMARY KEY,
++ value text COLLATE "C",
++ updated_at bigint NOT NULL
++);
++
++-- openvibe-publishing/revisions (prefix review_summary)
++CREATE TABLE IF NOT EXISTS review_summary_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 review_summary_revisions_entity_num ON review_summary_revisions (entity_id, number);
++CREATE TABLE IF NOT EXISTS review_summary_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 review_summary_drafts_updated ON review_summary_drafts (entity_id, updated_at DESC, owner);
++CREATE TABLE IF NOT EXISTS review_summary_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 review_summary_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'review_summary_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM review_summary_revision_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'review_summary_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER review_summary_revisions_no_update BEFORE UPDATE ON review_summary_revisions FOR EACH ROW EXECUTE FUNCTION review_summary_revisions_guard();
++CREATE OR REPLACE TRIGGER review_summary_revisions_no_delete BEFORE DELETE ON review_summary_revisions FOR EACH ROW EXECUTE FUNCTION review_summary_revisions_guard();
++
++-- openvibe-publishing/authorship (prefix review_summary)
++CREATE TABLE IF NOT EXISTS review_summary_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 review_summary_reviews_rev ON review_summary_reviews (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS review_summary_reviews_entity ON review_summary_reviews (entity_id, id);
++CREATE OR REPLACE FUNCTION review_summary_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'review_summary_reviews rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER review_summary_reviews_no_update BEFORE UPDATE ON review_summary_reviews FOR EACH ROW EXECUTE FUNCTION review_summary_reviews_guard();
++
++-- openvibe-publishing/seo (prefix review_entity)
++CREATE TABLE IF NOT EXISTS review_entity_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 review_entity_redirects_entity ON review_entity_redirects (entity_id, created_at, from_path);
++
++-- openvibe-publishing/discussion (prefix review)
++CREATE TABLE IF NOT EXISTS review_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/index-hooks (prefix review)
++CREATE TABLE IF NOT EXISTS review_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)
++);
++
++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
++CREATE TABLE IF NOT EXISTS review_event_inbox (
++ consumer text NOT NULL,
++ event_id text NOT NULL,
++ processed_at bigint NOT NULL,
++ PRIMARY KEY (consumer, event_id)
++);
++
++-- openvibe-sdk/events PostgreSQL outbox
++CREATE TABLE IF NOT EXISTS review_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 review_event_outbox_due ON review_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS review_event_outbox_sent ON review_event_outbox (sent_at) WHERE sent_at IS NOT NULL;
ddiff --git a/package-lock.json b/package-lock.json
iindex f29ad78..889bcfc 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.49.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.20.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.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "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.20.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
++ "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==",
"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 36525fb..b48dadc 100644
---- a/package.json
++++ b/package.json
@@@ -7,9 +7,14 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/run.js"
++ "test": "node test/run.js",
++ "test:pg": "REVIEWS_TEST_STORE=pg node test/run.js"
},
-- "keywords": ["openvibe", "reviews", "publishing"],
++ "keywords": [
++ "openvibe",
++ "reviews",
++ "publishing"
++ ],
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Reviews",
@@@ -19,12 +24,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.49.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.20.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..bb5a029
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -0,0 +1,26 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The one-time move of Reviews's SQLite database (REVIEWS_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. Every table keeps its
++ * name and columns.
++ */
++require('dotenv').config();
++const { runSqliteMigration } = require('openvibe-sdk/db');
++const configLib = require('../server/config');
++const { MIGRATIONS } = require('../server/db');
++
++const TABLES = {};
++
++if (require.main === module) {
++ const config = configLib.load();
++ runSqliteMigration({ service: 'reviews', 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/server/app.js b/server/app.js
iindex c409d77..f928c22 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -25,6 +25,22 @@ const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
++/**
++ * A gauge's collect() is synchronous and the database is not (ADR-035): each scrape returns what the previous scrape
++ * read (one interval behind) and starts the next read. It never throws.
++ */
++function lagged(read, initial) {
++ let last = initial;
++ let busy = false;
++ return () => {
++ if (!busy) {
++ busy = true;
++ Promise.resolve().then(read).then((v) => { last = v; }, () => {}).finally(() => { busy = false; });
++ }
++ return last;
++ };
++}
++
function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
const app = express();
app.disable('x-powered-by');
@@@ -33,22 +49,24 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
require('openvibe-shared/trace').install(app);
const release = require('openvibe-shared/release').createRelease({ service: 'reviews', root: path.join(__dirname, '..') });
++ // 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 metrics = require('openvibe-shared/metrics').instrument(app, { service: 'reviews', release: release.release });
metrics.registry.gauge({
name: 'reviews_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: lagged(async () => [{ labels: { state: 'pending' }, value: await platform.outbox.pending() }, { labels: { state: 'rejected' }, value: await platform.outbox.rejected() }], []),
});
metrics.registry.gauge({
name: 'reviews_work', help: 'Editorial and import backlog', labelNames: ['kind'],
-- collect: () => {
-- const s = svc.stats();
++ collect: lagged(async () => {
++ const s = await svc.stats();
return [
{ labels: { kind: 'items_unresolved' }, value: s.items_unresolved },
{ labels: { kind: 'corrections_open' }, value: s.corrections_open },
{ labels: { kind: 'summaries_flagged' }, value: s.summaries_flagged },
-- { labels: { kind: 'import_queue' }, value: sync.pending() },
++ { labels: { kind: 'import_queue' }, value: await sync.pending() },
];
-- },
++ }, []),
});
app.use((req, res, next) => {
@@@ -87,7 +105,17 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
const ready = createReadiness({
service: 'reviews', release: release.release,
checks: [
-- { name: 'db', required: true, check: () => db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('review_entities','review_signals','review_summary_revisions')").get().n === 3 || 'review tables missing' },
++ {
++ name: 'db', required: true,
++ // A real round trip that names the store (postgresql / pglite), and the authority tables present.
++ check: async () => {
++ const r = await db.ready();
++ if (!r.ok) return r.error;
++ const n = await db.prepare("SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('review_entities','review_signals','review_summary_revisions')").pluck().get();
++ return n === 3 ? { ok: true, detail: r.detail } : 'schema 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: 'sources', required: false, check: () => platform.sources.configured || 'not configured: no review items are read, so no signals and no aggregates exist' },
{ name: 'events_relay', required: false, check: () => (platform.eventsConfigured ? true : 'EVENTS_URL or the service principal is not configured: events wait in the outbox') },
@@@ -95,10 +123,10 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
{ name: 'community', required: false, check: () => platform.community.configured || 'not configured: discussions show as unavailable' },
{ name: 'editors', required: false, check: () => (svc.access.editorCount() ? true : 'REVIEWS_EDITORS is empty: only Network staff can edit') },
],
-- details: () => ({
-- outbox: { pending: platform.outbox.pending(), rejected: platform.outbox.rejected() },
-- sources: { cursor: sync.cursor(), import_queue: sync.pending(), last_pull_ok_at: sync.state('sources.last_pull_ok_at'), last_pull_error: sync.state('sources.last_pull_error') },
-- backlog: svc.stats(),
++ details: async () => ({
++ outbox: { pending: await platform.outbox.pending(), rejected: await platform.outbox.rejected() },
++ sources: { cursor: await sync.cursor(), import_queue: await sync.pending(), last_pull_ok_at: await sync.state('sources.last_pull_ok_at'), last_pull_error: await sync.state('sources.last_pull_error') },
++ backlog: await svc.stats(),
}),
});
app.get('/api/ready', ready.handler);
@@@ -107,7 +135,7 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
app.use('/api/v1/entities/:ref/corrections', limiter(60 * 60000, 20));
// Per-actor limits (http/actor-limits.js) on /api/v1 and the 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, sync, config, log, limits }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex 66d3390..ccea94d 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -97,7 +97,7 @@ function createViewerResolver({ keys, config }) {
const payload = decodePayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (opts.services === false) return ANONYMOUS;
-- return fromServiceToken(req, token);
++ return await fromServiceToken(req, token);
}
return (await fromUserToken(token, { strict: true })) || ANONYMOUS;
}
ddiff --git a/server/config.js b/server/config.js
iindex 958bb6c..fd46594 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -31,6 +31,10 @@ function load(env = process.env) {
minute: Math.max(1, int(env.REVIEWS_LIMITS_MINUTE, 120)),
hour: Math.max(1, int(env.REVIEWS_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:reviews:' },
++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.REVIEWS_DB_PATH || './data/reviews.db',
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
ddiff --git a/server/db.js b/server/db.js
iindex 49097e0..fcbc2f4 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -1,6 +1,6 @@
'use strict';
/**
-- * Reviews' own SQLite database (one per service, WAL, created on boot, idempotent).
++ * Reviews' own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot.
*
* The nine authority tables of roadmap §15.13:
* review_entities the things reviewed; merged entities point at their canonical entity
@@@ -27,7 +27,7 @@
*/
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 { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createReviewLog } = require('openvibe-publishing/authorship');
@@@ -42,264 +42,30 @@ const TRUST_KEYS = ['aggregate', 'limitation', 'verification'];
const q = (list) => list.map((s) => `'${s}'`).join(',');
--const SCHEMA = `
--CREATE TABLE IF NOT EXISTS review_entities (
-- id TEXT PRIMARY KEY,
-- slug TEXT NOT NULL UNIQUE,
-- name TEXT NOT NULL,
-- kind TEXT NOT NULL CHECK (kind IN (${q(ENTITY_KINDS)})),
-- description TEXT,
-- state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','deleted')),
-- merged_into TEXT REFERENCES review_entities(id),
-- noindex INTEGER NOT NULL DEFAULT 0,
-- created_by TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- deleted_at INTEGER,
-- CHECK ((state = 'merged') = (merged_into IS NOT NULL))
--);
--CREATE INDEX IF NOT EXISTS review_entities_merged ON review_entities (merged_into);
--CREATE INDEX IF NOT EXISTS review_entities_name ON review_entities (name COLLATE NOCASE);
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
--CREATE TABLE IF NOT EXISTS review_entity_aliases (
-- id TEXT PRIMARY KEY,
-- entity_id TEXT NOT NULL REFERENCES review_entities(id),
-- type TEXT NOT NULL CHECK (type IN (${q(ALIAS_TYPES)})),
-- value TEXT NOT NULL,
-- norm TEXT NOT NULL,
-- created_by TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- removed_at INTEGER,
-- removed_by TEXT
--);
---- A strong identifier (URL, SKU, GTIN, MPN, source binding, external id) names one entity at a time;
---- names may be shared (two things called "Portal" is why names need an editor's confirmation).
--CREATE UNIQUE INDEX IF NOT EXISTS review_entity_aliases_strong ON review_entity_aliases (type, norm) WHERE removed_at IS NULL AND type != 'name';
--CREATE UNIQUE INDEX IF NOT EXISTS review_entity_aliases_name ON review_entity_aliases (norm, entity_id) WHERE removed_at IS NULL AND type = 'name';
--CREATE INDEX IF NOT EXISTS review_entity_aliases_entity ON review_entity_aliases (entity_id);
--
--CREATE TABLE IF NOT EXISTS review_sources (
-- key TEXT PRIMARY KEY,
-- name TEXT,
-- homepage_url TEXT,
-- category TEXT,
-- license_note TEXT,
-- terms_note TEXT,
-- status TEXT,
-- stale INTEGER,
-- last_success_at TEXT,
-- first_seen_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL
--);
--
--CREATE TABLE IF NOT EXISTS review_source_items (
-- id TEXT PRIMARY KEY,
-- source_key TEXT NOT NULL REFERENCES review_sources(key),
-- kind TEXT NOT NULL,
-- identity TEXT NOT NULL,
-- canonical_url TEXT,
-- title TEXT,
-- item_revision INTEGER NOT NULL,
-- content_hash TEXT NOT NULL,
-- published_at TEXT,
-- retrieved_at TEXT NOT NULL,
-- parser_version TEXT,
-- license_note TEXT,
-- terms_note TEXT,
-- fields TEXT NOT NULL DEFAULT '{}',
-- state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','removed')),
-- removed_at TEXT,
-- removed_reason TEXT,
-- resolution TEXT NOT NULL DEFAULT 'unmatched' CHECK (resolution IN ('resolved','ambiguous','unmatched','ignored')),
-- entity_id TEXT REFERENCES review_entities(id),
-- resolution_rule TEXT,
-- candidates TEXT NOT NULL DEFAULT '[]',
-- resolved_by TEXT,
-- resolved_at INTEGER,
-- signal_note TEXT,
-- first_seen_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- CHECK ((resolution = 'resolved') = (entity_id IS NOT NULL))
--);
--CREATE INDEX IF NOT EXISTS review_source_items_resolution ON review_source_items (resolution, updated_at);
--CREATE INDEX IF NOT EXISTS review_source_items_entity ON review_source_items (entity_id);
--CREATE INDEX IF NOT EXISTS review_source_items_source ON review_source_items (source_key);
--
--CREATE TABLE IF NOT EXISTS review_signals (
-- id TEXT PRIMARY KEY,
-- entity_id TEXT NOT NULL REFERENCES review_entities(id),
-- source_item_id TEXT NOT NULL REFERENCES review_source_items(id),
-- source_key TEXT NOT NULL REFERENCES review_sources(key),
-- item_revision INTEGER NOT NULL,
-- type TEXT NOT NULL CHECK (type IN (${q(SIGNAL_TYPES)})),
-- recommended INTEGER CHECK (recommended IN (0,1)),
-- positive_count INTEGER,
-- total_count INTEGER,
-- rating_value REAL,
-- rating_best REAL,
-- rating_worst REAL,
-- rating_count INTEGER,
-- observed_at TEXT NOT NULL,
-- source_published_at TEXT,
-- canonical_url TEXT,
-- license_note TEXT,
-- trust TEXT NOT NULL DEFAULT '{}',
-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','withdrawn')),
-- status_reason TEXT,
-- status_at INTEGER,
-- superseded_by TEXT,
-- created_by TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- CHECK (type != 'recommendation' OR recommended IS NOT NULL),
-- CHECK (type != 'recommendation_tally' OR (positive_count IS NOT NULL AND total_count IS NOT NULL AND positive_count >= 0 AND total_count >= positive_count)),
-- CHECK (type NOT IN ('rating','rating_aggregate') OR rating_value IS NOT NULL)
--);
---- At most one active signal per source item: an update supersedes, a removal withdraws.
--CREATE UNIQUE INDEX IF NOT EXISTS review_signals_active_item ON review_signals (source_item_id) WHERE status = 'active';
--CREATE INDEX IF NOT EXISTS review_signals_entity ON review_signals (entity_id, status);
---- Provenance and values never change after the signal exists; only its status moves.
--CREATE TRIGGER IF NOT EXISTS review_signals_immutable BEFORE UPDATE OF id, entity_id, source_item_id, source_key, item_revision, type,
-- recommended, positive_count, total_count, rating_value, rating_best, rating_worst, rating_count, observed_at,
-- source_published_at, canonical_url, license_note, trust, created_by, created_at ON review_signals
--BEGIN SELECT RAISE(ABORT, 'review_signals values and provenance are immutable'); END;
--CREATE TRIGGER IF NOT EXISTS review_signals_no_delete BEFORE DELETE ON review_signals
--BEGIN SELECT RAISE(ABORT, 'review_signals rows are withdrawn, never deleted'); END;
--
--CREATE TABLE IF NOT EXISTS review_summaries (
-- id TEXT PRIMARY KEY,
-- entity_id TEXT NOT NULL UNIQUE REFERENCES review_entities(id),
-- state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','published','unpublished')),
-- published_revision INTEGER,
-- published_at INTEGER,
-- revision_published_at INTEGER,
-- flagged INTEGER NOT NULL DEFAULT 0,
-- flag_reason TEXT,
-- flagged_at INTEGER,
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL
--);
--
--CREATE TABLE IF NOT EXISTS review_summary_citations (
-- summary_id TEXT NOT NULL REFERENCES review_summaries(id),
-- revision INTEGER NOT NULL,
-- point TEXT NOT NULL,
-- signal_id TEXT NOT NULL REFERENCES review_signals(id),
-- PRIMARY KEY (summary_id, revision, point, signal_id)
--);
--CREATE INDEX IF NOT EXISTS review_summary_citations_signal ON review_summary_citations (signal_id);
--CREATE TRIGGER IF NOT EXISTS review_summary_citations_no_update BEFORE UPDATE ON review_summary_citations
--BEGIN SELECT RAISE(ABORT, 'review_summary_citations rows are immutable'); END;
--CREATE TRIGGER IF NOT EXISTS review_summary_citations_no_delete BEFORE DELETE ON review_summary_citations
--BEGIN SELECT RAISE(ABORT, 'review_summary_citations rows are never deleted'); END;
--
--CREATE TABLE IF NOT EXISTS review_trust_metadata (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- scope TEXT NOT NULL CHECK (scope IN ('source','signal','entity')),
-- scope_id TEXT NOT NULL,
-- key TEXT NOT NULL CHECK (key IN (${q(TRUST_KEYS)})),
-- value TEXT NOT NULL,
-- note TEXT,
-- set_by TEXT NOT NULL,
-- set_at INTEGER NOT NULL,
-- ended_at INTEGER,
-- ended_by TEXT
--);
--CREATE UNIQUE INDEX IF NOT EXISTS review_trust_current ON review_trust_metadata (scope, scope_id, key) WHERE ended_at IS NULL;
--
--CREATE TABLE IF NOT EXISTS review_entity_links (
-- id TEXT PRIMARY KEY,
-- from_entity TEXT NOT NULL REFERENCES review_entities(id),
-- to_entity TEXT REFERENCES review_entities(id),
-- type TEXT NOT NULL CHECK (type IN (${q(LINK_TYPES)})),
-- ref TEXT,
-- note TEXT,
-- created_by TEXT NOT NULL,
-- created_at INTEGER NOT NULL,
-- ended_at INTEGER,
-- ended_by TEXT,
-- end_note TEXT,
-- CHECK ((type = 'external_ref') = (ref IS NOT NULL AND to_entity IS NULL))
--);
--CREATE INDEX IF NOT EXISTS review_entity_links_from ON review_entity_links (from_entity, type);
--CREATE INDEX IF NOT EXISTS review_entity_links_to ON review_entity_links (to_entity, type);
--CREATE UNIQUE INDEX IF NOT EXISTS review_entity_links_one_merge ON review_entity_links (from_entity) WHERE type = 'merged_into' AND ended_at IS NULL;
--
--CREATE TABLE IF NOT EXISTS review_aggregates (
-- entity_id TEXT NOT NULL REFERENCES review_entities(id),
-- revision INTEGER NOT NULL,
-- computed_at INTEGER NOT NULL,
-- trigger TEXT NOT NULL,
-- inputs_hash TEXT NOT NULL,
-- result TEXT,
-- PRIMARY KEY (entity_id, revision)
--);
--CREATE TRIGGER IF NOT EXISTS review_aggregates_no_update BEFORE UPDATE ON review_aggregates
--BEGIN SELECT RAISE(ABORT, 'review_aggregates rows are immutable'); END;
--CREATE TRIGGER IF NOT EXISTS review_aggregates_no_delete BEFORE DELETE ON review_aggregates
--BEGIN SELECT RAISE(ABORT, 'review_aggregates rows are never deleted'); END;
--
--CREATE TABLE IF NOT EXISTS review_corrections (
-- id TEXT PRIMARY KEY,
-- entity_id TEXT NOT NULL REFERENCES review_entities(id),
-- target_type TEXT NOT NULL CHECK (target_type IN ('entity','alias','signal','summary','aggregate')),
-- target_id TEXT,
-- body TEXT NOT NULL,
-- evidence_url TEXT,
-- submitted_by TEXT NOT NULL,
-- via TEXT,
-- status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','accepted','rejected')),
-- resolved_by TEXT,
-- resolution_note TEXT,
-- resolved_at INTEGER,
-- created_at INTEGER NOT NULL
--);
--CREATE INDEX IF NOT EXISTS review_corrections_status ON review_corrections (status, created_at);
--CREATE INDEX IF NOT EXISTS review_corrections_entity ON review_corrections (entity_id, status);
--CREATE INDEX IF NOT EXISTS review_corrections_by ON review_corrections (submitted_by, created_at);
--
--CREATE TABLE IF NOT EXISTS review_audit (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- at INTEGER NOT NULL,
-- actor TEXT NOT NULL,
-- action TEXT NOT NULL,
-- entity_id TEXT,
-- target TEXT,
-- detail TEXT NOT NULL DEFAULT '{}'
--);
--CREATE INDEX IF NOT EXISTS review_audit_entity ON review_audit (entity_id, id);
--CREATE TRIGGER IF NOT EXISTS review_audit_no_update BEFORE UPDATE ON review_audit
--BEGIN SELECT RAISE(ABORT, 'review_audit rows are immutable'); END;
--CREATE TRIGGER IF NOT EXISTS review_audit_no_delete BEFORE DELETE ON review_audit
--BEGIN SELECT RAISE(ABORT, 'review_audit rows are never deleted'); END;
--
--CREATE TABLE IF NOT EXISTS review_import_queue (
-- item_id TEXT PRIMARY KEY,
-- reason TEXT NOT NULL,
-- enqueued_at INTEGER NOT NULL,
-- attempts INTEGER NOT NULL DEFAULT 0,
-- next_attempt_at INTEGER NOT NULL DEFAULT 0,
-- last_error TEXT
--);
--
--CREATE TABLE IF NOT EXISTS review_sync_state (
-- name TEXT PRIMARY KEY,
-- value TEXT,
-- updated_at INTEGER NOT NULL
--);
--`;
--
--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;
++/**
++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
++ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
++ */
++async function openDb(config, { log = console, registry } = {}) {
++ 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 reviews)');
++ log.warn(`[Reviews] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
++ fs.mkdirSync(DEV_PGLITE, { recursive: true });
++ const db = createDb({ pglite: DEV_PGLITE, service: 'reviews', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ 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: 'reviews-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'reviews', registry, log });
}
--/** Creates every table and returns the Publishing package stores bound to this database. */
++/** The Publishing package stores bound to this (migrated) database. */
function createStores(db, { now = () => Date.now() } = {}) {
-- db.exec(SCHEMA);
const revisions = createRevisionStore(db, { prefix: 'review_summary', now });
return {
revisions,
@@@ -315,4 +81,4 @@ const AUTHORITY_TABLES = Object.freeze([
'review_summaries', 'review_summary_revisions', 'review_trust_metadata', 'review_entity_links',
]);
--module.exports = { openDb, createStores, SCHEMA, AUTHORITY_TABLES, ENTITY_KINDS, ALIAS_TYPES, SIGNAL_TYPES, LINK_TYPES, TRUST_KEYS };
++module.exports = { openDb, createStores, MIGRATIONS, AUTHORITY_TABLES, ENTITY_KINDS, ALIAS_TYPES, SIGNAL_TYPES, LINK_TYPES, TRUST_KEYS };
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex ff08276..81fa20f 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -25,7 +25,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the pages people read,
* and the signed Events deliveries at /internal/events.
*/
--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
@@@ -83,7 +83,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: 'reviews_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
@@@ -91,6 +91,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 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 35c71c3..cc5c930 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -66,70 +66,70 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limits
const R = (fn, status) => run(fn, status, log);
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
-- function live(ref) {
-- const e = svc.entity(ref);
++ async function live(ref) {
++ const e = await svc.entity(ref);
if (e.state === 'deleted') throw new svc.ReviewsError(410, 'entity.deleted', 'This entity was deleted');
-- if (e.state === 'merged') throw new svc.ReviewsError(409, 'entity.merged', `Merged into ${svc.canonicalOf(e.id)}`, { canonical_id: svc.canonicalOf(e.id) });
++ if (e.state === 'merged') throw new svc.ReviewsError(409, 'entity.merged', `Merged into ${await svc.canonicalOf(e.id)}`, { canonical_id: await svc.canonicalOf(e.id) });
return e;
}
-- router.get('/entities', guard('reviews.entity.resolve'), R((req) => {
++ router.get('/entities', guard('reviews.entity.resolve'), R(async (req) => {
const text = String(req.query.q || '').trim();
-- if (text) return { entities: svc.search(text, { limit: Number(req.query.limit) || 30 }).map(svc.entityView) };
-- const out = svc.listEntities({ limit: Number(req.query.limit) || 50, offset: Number(req.query.offset) || 0 });
-- return { entities: out.entities.map(svc.entityView), total: out.total };
++ if (text) return { entities: (await Promise.all((await svc.search(text, { limit: Number(req.query.limit) || 30 })).map(svc.entityView))) };
++ const out = await svc.listEntities({ limit: Number(req.query.limit) || 50, offset: Number(req.query.offset) || 0 });
++ return { entities: (await Promise.all(out.entities.map(svc.entityView))), total: out.total };
}));
// A lookup, not a write: counted like a read, in the same budget.
-- router.post('/resolve', guard('reviews.entity.resolve'), limits.lookup('reviews.read'), R((req) => svc.resolve(body(req))));
-- router.get('/entities/:ref', guard('reviews.entity.resolve'), R((req) => {
-- const e = svc.entity(req.params.ref);
-- if (e.state !== 'active') return { entity: svc.entityView(e), canonical: e.state === 'merged' ? svc.entityView(svc.entityById(svc.canonicalOf(e.id))) : null };
-- const p = svc.page(e, req.actor);
++ router.post('/resolve', guard('reviews.entity.resolve'), limits.lookup('reviews.read'), R(async (req) => await svc.resolve(body(req))));
++ router.get('/entities/:ref', guard('reviews.entity.resolve'), R(async (req) => {
++ const e = await svc.entity(req.params.ref);
++ if (e.state !== 'active') return { entity: await svc.entityView(e), canonical: e.state === 'merged' ? await svc.entityView(await svc.entityById(await svc.canonicalOf(e.id))) : null };
++ const p = await svc.page(e, req.actor);
return { ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } };
}));
-- router.get('/entities/:ref/history', guard('reviews.entity.resolve'), R((req) => svc.history(svc.entity(req.params.ref), req.actor)));
-- router.get('/entities/:ref/aggregate', guard('reviews.entity.resolve'), R((req) => {
-- const e = live(req.params.ref);
-- const p = svc.page(e, req.actor);
++ router.get('/entities/:ref/history', guard('reviews.entity.resolve'), R(async (req) => await svc.history(await svc.entity(req.params.ref), req.actor)));
++ router.get('/entities/:ref/aggregate', guard('reviews.entity.resolve'), R(async (req) => {
++ const e = await live(req.params.ref);
++ const p = await svc.page(e, req.actor);
return { entity_id: e.id, aggregate: p.aggregate, aggregate_revision: p.aggregate_revision };
}));
-- router.post('/entities', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => {
-- const out = svc.createEntity(body(req), req.actor);
-- return { entity: svc.entityView(out.entity), settled_items: out.settled_items };
++ router.post('/entities', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => {
++ const out = await svc.createEntity(body(req), req.actor);
++ return { entity: await svc.entityView(out.entity), settled_items: out.settled_items };
}, 201));
-- router.patch('/entities/:ref', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.updateEntity(req.params.ref, body(req), req.actor)) })));
-- router.delete('/entities/:ref', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.deleteEntity(req.params.ref, body(req), req.actor)) })));
-- router.post('/entities/:ref/aliases', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => svc.addAlias(req.params.ref, body(req), req.actor), 201));
-- router.delete('/entities/:ref/aliases/:id', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ alias: svc.removeAlias(req.params.ref, req.params.id, req.actor) })));
-- router.post('/entities/:ref/links', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ link: svc.addLink(req.params.ref, body(req), req.actor) }), 201));
-- router.delete('/entities/:ref/links/:id', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ link: svc.endLink(req.params.ref, req.params.id, body(req), req.actor) })));
-- router.post('/entities/:ref/merge', guard('reviews.entity.merge'), B('reviews.entity.merge'), R((req) => {
-- const out = svc.merge(req.params.ref, body(req), req.actor);
-- return { link: out.link, entity: svc.entityView(out.entity), into: svc.entityView(out.into), signals: out.signals };
++ router.patch('/entities/:ref', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => ({ entity: await svc.entityView(await svc.updateEntity(req.params.ref, body(req), req.actor)) })));
++ router.delete('/entities/:ref', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => ({ entity: await svc.entityView(await svc.deleteEntity(req.params.ref, body(req), req.actor)) })));
++ router.post('/entities/:ref/aliases', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => await svc.addAlias(req.params.ref, body(req), req.actor), 201));
++ router.delete('/entities/:ref/aliases/:id', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => ({ alias: await svc.removeAlias(req.params.ref, req.params.id, req.actor) })));
++ router.post('/entities/:ref/links', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => ({ link: await svc.addLink(req.params.ref, body(req), req.actor) }), 201));
++ router.delete('/entities/:ref/links/:id', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => ({ link: await svc.endLink(req.params.ref, req.params.id, body(req), req.actor) })));
++ router.post('/entities/:ref/merge', guard('reviews.entity.merge'), B('reviews.entity.merge'), R(async (req) => {
++ const out = await svc.merge(req.params.ref, body(req), req.actor);
++ return { link: out.link, entity: await svc.entityView(out.entity), into: await svc.entityView(out.into), signals: out.signals };
}));
-- router.post('/entities/:ref/split', guard('reviews.entity.split'), B('reviews.entity.merge'), R((req) => {
-- const out = svc.split(req.params.ref, body(req), req.actor);
-- return { link: out.link, entity: svc.entityView(out.entity), from: out.from ? svc.entityView(out.from) : null, signals: out.signals };
++ router.post('/entities/:ref/split', guard('reviews.entity.split'), B('reviews.entity.merge'), R(async (req) => {
++ const out = await svc.split(req.params.ref, body(req), req.actor);
++ return { link: out.link, entity: await svc.entityView(out.entity), from: out.from ? await svc.entityView(out.from) : null, signals: out.signals };
}));
-- router.post('/trust', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => svc.setTrust(body(req), req.actor)));
++ router.post('/trust', guard('reviews.entity.manage'), B('reviews.entity.manage'), R(async (req) => await svc.setTrust(body(req), req.actor)));
-- router.get('/items', guard('reviews.entity.resolve'), R((req) => {
++ router.get('/items', guard('reviews.entity.resolve'), R(async (req) => {
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'The resolution queue is for Reviews editors');
-- return { items: svc.resolutionQueue(Number(req.query.limit) || 200).map(svc.itemView) };
++ return { items: (await Promise.all((await svc.resolutionQueue(Number(req.query.limit) || 200)).map(svc.itemView))) };
}));
-- router.get('/items/:id', guard('reviews.entity.resolve'), R((req) => {
-- const row = svc.item(req.params.id);
++ router.get('/items/:id', guard('reviews.entity.resolve'), R(async (req) => {
++ const row = await svc.item(req.params.id);
if (!row) throw new svc.ReviewsError(404, 'item.not_found', 'Reviews has not read that item');
// Taken down at its source: gone, like a deleted entity (its record stays for the audit).
if (row.state !== 'active') throw new svc.ReviewsError(410, 'item.removed', 'Its source removed this item');
-- return { item: svc.itemView(row) };
++ return { item: await svc.itemView(row) };
}));
-- router.post('/items/:id/resolution', guard('reviews.entity.resolve'), B('reviews.item.resolve'), R((req) => {
++ router.post('/items/:id/resolution', guard('reviews.entity.resolve'), B('reviews.item.resolve'), R(async (req) => {
const b = body(req);
-- if (b.ignore === true) return { item: svc.itemView(svc.ignoreItem(req.params.id, b, req.actor)) };
-- const out = svc.confirmResolution(req.params.id, b, req.actor);
-- return { item: svc.itemView(out.item), signal: out.signal ? svc.signalView(out.signal) : null, settled_items: out.settled_items };
++ if (b.ignore === true) return { item: await svc.itemView(await svc.ignoreItem(req.params.id, b, req.actor)) };
++ const out = await svc.confirmResolution(req.params.id, b, req.actor);
++ return { item: await svc.itemView(out.item), signal: out.signal ? await svc.signalView(out.signal) : null, settled_items: out.settled_items };
}));
router.post('/signals/import', guard('reviews.signal.import'), B('reviews.signal.import'), R(async (req) => {
@@@ -137,7 +137,7 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limits
if (a.kind === 'anonymous' || (a.kind === 'user' && !svc.access.isEditor(a))) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Importing is for Reviews editors and granted services');
const b = body(req);
const out = await svc.importItem(b.source_item_id || b.item_id, platform.sources, a);
-- return { outcome: out.outcome, item: svc.itemView(out.item), signal: out.signal ? svc.signalView(out.signal) : null };
++ return { outcome: out.outcome, item: await svc.itemView(out.item), signal: out.signal ? await svc.signalView(out.signal) : null };
}));
router.post('/sources/sync', guard('reviews.signal.import'), B('reviews.sources.sync'), R(async (req) => {
const a = req.actor;
@@@ -147,26 +147,26 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limits
return { pull: pulled, queue: drained };
}));
-- router.post('/entities/:ref/summary/revisions', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => svc.writeSummary(req.params.ref, body(req), req.actor), 201));
-- router.post('/entities/:ref/summary/proposals', guard('reviews.summary.propose'), B('reviews.summary.propose'), R((req) => svc.proposeSummary(req.params.ref, body(req), req.actor), 201));
-- router.get('/entities/:ref/summary/revisions/:n', guard('reviews.entity.resolve'), R((req) => ({ revision: svc.summaryRevision(svc.entity(req.params.ref), req.params.n, req.actor) })));
-- router.post('/entities/:ref/summary/revisions/:n/review', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => svc.reviewSummary(req.params.ref, req.params.n, body(req), req.actor)));
-- router.post('/entities/:ref/summary/publish', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => ({ summary: svc.publishSummary(req.params.ref, body(req), req.actor) })));
-- router.post('/entities/:ref/summary/unpublish', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => ({ summary: svc.unpublishSummary(req.params.ref, req.actor) })));
++ router.post('/entities/:ref/summary/revisions', guard('reviews.summary.publish'), B('reviews.summary.publish'), R(async (req) => await svc.writeSummary(req.params.ref, body(req), req.actor), 201));
++ router.post('/entities/:ref/summary/proposals', guard('reviews.summary.propose'), B('reviews.summary.propose'), R(async (req) => await svc.proposeSummary(req.params.ref, body(req), req.actor), 201));
++ router.get('/entities/:ref/summary/revisions/:n', guard('reviews.entity.resolve'), R(async (req) => ({ revision: await svc.summaryRevision(await svc.entity(req.params.ref), req.params.n, req.actor) })));
++ router.post('/entities/:ref/summary/revisions/:n/review', guard('reviews.summary.publish'), B('reviews.summary.publish'), R(async (req) => await svc.reviewSummary(req.params.ref, req.params.n, body(req), req.actor)));
++ router.post('/entities/:ref/summary/publish', guard('reviews.summary.publish'), B('reviews.summary.publish'), R(async (req) => ({ summary: await svc.publishSummary(req.params.ref, body(req), req.actor) })));
++ router.post('/entities/:ref/summary/unpublish', guard('reviews.summary.publish'), B('reviews.summary.publish'), R(async (req) => ({ summary: await svc.unpublishSummary(req.params.ref, req.actor) })));
-- router.post('/entities/:ref/corrections', guard('reviews.correction.submit'), B('reviews.correction.submit'), R((req) => ({ correction: svc.submitCorrection(req.params.ref, body(req), req.actor) }), 201));
-- router.get('/corrections', guard('reviews.entity.manage'), R((req) => {
++ router.post('/entities/:ref/corrections', guard('reviews.correction.submit'), B('reviews.correction.submit'), R(async (req) => ({ correction: await svc.submitCorrection(req.params.ref, body(req), req.actor) }), 201));
++ router.get('/corrections', guard('reviews.entity.manage'), R(async (req) => {
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');
-- return { corrections: svc.openCorrections() };
++ return { corrections: await svc.openCorrections() };
}));
-- router.patch('/corrections/:id', guard('reviews.entity.manage'), B('reviews.correction.resolve'), R((req) => {
++ router.patch('/corrections/:id', guard('reviews.entity.manage'), B('reviews.correction.resolve'), R(async (req) => {
const b = body(req);
// Accepting with a correction publishes a summary revision: a service needs that grant too.
if (req.actor.kind === 'service' && b.status === 'accepted' && (b.correction_note || b.summary)) {
const c = checkCapability(req.actor.claims, 'reviews.summary.publish');
if (!c.allowed) throw new svc.ReviewsError(403, c.code, c.reason);
}
-- return svc.resolveCorrection(req.params.id, b, req.actor);
++ return await svc.resolveCorrection(req.params.id, b, req.actor);
}));
return router;
ddiff --git a/server/http/consumer.js b/server/http/consumer.js
iindex 6132fd3..31a1a4a 100644
---- a/server/http/consumer.js
++++ b/server/http/consumer.js
@@@ -15,32 +15,31 @@
*/
const express = require('express');
const { http } = require('openvibe-contracts');
--const { parseDelivery, createInbox } = require('openvibe-sdk/events');
++const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const CONSUMER = 'reviews-sources';
function consumerRouter({ db, svc, sync, config, log = console }) {
const router = express.Router();
-- const inbox = createInbox(db, { table: 'review_event_inbox' });
-- inbox.ensureSchema();
++ const inbox = createPgInbox(db, { table: 'review_event_inbox' }); // the table is in migrations/
const system = { kind: 'system', service: 'svc:reviews' };
-- function apply(event) {
-- return db.transaction(() => {
-- const r = inbox.once(CONSUMER, event.event_id, () => {
++ async function apply(event) {
++ return await db.tx(async () => {
++ const r = await inbox.once(CONSUMER, event.event_id, async () => {
if (event.source !== 'sources') return 'ignored:source';
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
if (p.category !== 'reviews') return 'ignored:category';
if (!/^itm_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(p.item_id || ''))) return 'ignored:no_item';
-- if (event.event_type === 'sources.item.created' || event.event_type === 'sources.item.updated') { sync.enqueue(p.item_id, event.event_type); return 'queued'; }
-- if (event.event_type === 'sources.item.removed') return svc.removeItem(p.item_id, p.reason || 'removed by its source', system).outcome;
++ if (event.event_type === 'sources.item.created' || event.event_type === 'sources.item.updated') { await sync.enqueue(p.item_id, event.event_type); return 'queued'; }
++ if (event.event_type === 'sources.item.removed') return (await svc.removeItem(p.item_id, p.reason || 'removed by its source', system)).outcome;
return 'ignored:type';
});
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
-- })();
++ });
}
-- router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
++ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
const secrets = config.eventsWebhookSecrets;
if (!secrets.length) return http.sendProblem(res, 503, 'reviews.webhook_disabled', { detail: 'REVIEWS_EVENTS_SECRET is not set', ctx: req.ov });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
@@@ -54,7 +53,7 @@ function consumerRouter({ db, svc, sync, config, log = console }) {
}
let out;
try {
-- out = apply(event);
++ out = await apply(event);
} catch (e) {
log.error(`[Reviews] event ${event.event_id} (${event.event_type}) failed:`, e.message);
return http.sendProblem(res, 500, 'reviews.event_failed', { detail: 'processing failed; it will be retried', ctx: req.ov });
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex 64232a1..782b4ab 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -27,12 +27,12 @@ function createMachine({ svc, config }) {
}));
});
-- function entityEntries() {
-- return svc.publicEntities().map(({ entity, decision }) => ({ loc: svc.entityUrl(entity), lastmod: entity.updated_at, decision }));
++ async function entityEntries() {
++ return (await svc.publicEntities()).map(({ entity, decision }) => ({ loc: svc.entityUrl(entity), lastmod: entity.updated_at, decision }));
}
-- router.get('/sitemap.xml', (_req, res) => {
-- const pages = seo.sitemap(entityEntries(), { maxUrls: PER_SITEMAP });
++ router.get('/sitemap.xml', async (_req, res) => {
++ const pages = seo.sitemap(await entityEntries(), { maxUrls: PER_SITEMAP });
const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
cache(res).type('application/xml').send(seo.sitemapIndex(maps));
@@@ -42,15 +42,15 @@ function createMachine({ svc, config }) {
const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));
cache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
});
-- router.get('/sitemaps/entities-:n.xml', (req, res) => {
-- const files = seo.sitemap(entityEntries(), { maxUrls: PER_SITEMAP }).files;
++ router.get('/sitemaps/entities-:n.xml', async (req, res) => {
++ const files = seo.sitemap(await entityEntries(), { 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.recentSummaries(50).map(({ summary, entity, rev, decision }) => ({
++ async function feedItems() {
++ return (await svc.recentSummaries(50)).map(({ summary, entity, rev, decision }) => ({
id: `tag:openvibe.reviews,2026:summary/${summary.id}/revision/${summary.published_revision}`,
url: `${svc.entityUrl(entity)}#summary`,
title: `${entity.name}: summary`,
@@@ -63,13 +63,13 @@ function createMachine({ svc, config }) {
}));
}
-- router.get('/feed.atom', (_req, res) => {
-- const items = feedItems();
++ router.get('/feed.atom', async (_req, res) => {
++ const items = await feedItems();
if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('No summary has been published yet.');
cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
});
-- router.get('/feed.json', (_req, res) => {
-- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, feedItems())));
++ router.get('/feed.json', async (_req, res) => {
++ cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, await feedItems())));
});
router.get('/llms.txt', (_req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4c2fa9f..99e3554 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -121,12 +121,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
};
/** :slug → an active entity, or the right redirect / 410 / 404. */
-- function locate(req, res, suffix = '') {
++ async function locate(req, res, suffix = '') {
const slug = req.params.slug;
-- const e = svc.findEntity(slug);
++ const e = await svc.findEntity(slug);
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
if (!e) {
-- const r = svc.resolveRedirect(`/e/${slug}`);
++ const r = await svc.resolveRedirect(`/e/${slug}`);
if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + query); return null; }
if (r && r.status === 410) { gone(req, res); return null; }
notFound(req, res);
@@@ -134,7 +134,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}
if (e.state === 'deleted') { gone(req, res); return null; }
if (e.state === 'merged') {
-- const c = svc.entityById(svc.canonicalOf(e.id));
++ const c = await svc.entityById(await svc.canonicalOf(e.id));
res.set('Cache-Control', 'public, max-age=60').redirect(301, `${svc.entityPath(c)}${suffix}${query}`);
return null;
}
@@@ -142,9 +142,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}
// ── Public ───────────────────────────────────────────────
-- router.get('/', (req, res) => {
-- const all = svc.listEntities({ limit: 1 });
-- send(req, res, 200, views.home({ entities: svc.entitiesWithData(60), total: all.total, editor: isEditor(req) }), {
++ router.get('/', async (req, res) => {
++ const all = await svc.listEntities({ limit: 1 });
++ send(req, res, 200, views.home({ entities: await svc.entitiesWithData(60), total: all.total, editor: isEditor(req) }), {
robots: 'index, follow', cache: 'public', active: 'home', path: '/',
jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Reviews', description: 'Review signals from named sources, with provenance and honest aggregates.' }),
});
@@@ -152,22 +152,22 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'reviews', siteName: 'OpenVibe.Reviews' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Reviews', robots: 'index, follow', cache: 'public', path: '/updates' }));
router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));
-- router.get('/search', (req, res) => {
++ router.get('/search', async (req, res) => {
const query = String(req.query.q || '').slice(0, 200);
-- send(req, res, 200, views.searchPage({ query, results: query ? svc.search(query) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query });
++ send(req, res, 200, views.searchPage({ query, results: query ? await svc.search(query) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query });
});
-- router.get('/e/:slug.json', (req, res) => {
-- const e = locate(req, res, '.json');
++ router.get('/e/:slug.json', async (req, res) => {
++ const e = await locate(req, res, '.json');
if (!e) return;
-- const p = svc.page(e, req.actor);
++ const p = await svc.page(e, req.actor);
res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').set('X-Robots-Tag', p.decision.robots)
.json({ ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } });
});
async function discussionFor(e, actor) {
if (!platform.community.configured) return { state: 'unavailable', reason: 'OpenVibe.Community is not configured on this server' };
-- const threadId = svc.knownThread(e.id);
++ const threadId = await svc.knownThread(e.id);
if (!threadId) return { state: 'none' };
try {
const data = await platform.community.getThread(threadId, { subject: actor.subject });
@@@ -178,9 +178,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}
router.get('/e/:slug', wrap(async (req, res) => {
-- const e = locate(req, res);
++ const e = await locate(req, res);
if (!e) return;
-- const p = svc.page(e, req.actor);
++ const p = await svc.page(e, req.actor);
const discussion = await discussionFor(e, req.actor);
const head = seo.metaTags({
decision: p.decision, title: `${e.name} · OpenVibe.Reviews`, siteName: 'OpenVibe.Reviews', type: 'website',
@@@ -192,41 +192,41 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
});
}));
-- router.get('/e/:slug/history', (req, res) => {
-- const e = locate(req, res, '/history');
++ router.get('/e/:slug/history', async (req, res) => {
++ const e = await locate(req, res, '/history');
if (!e) return;
-- send(req, res, 200, views.historyPage(svc.history(e, req.actor)), { title: `History of ${e.name}`, robots: 'noindex, follow', cache: 'public' });
++ send(req, res, 200, views.historyPage(await svc.history(e, req.actor)), { title: `History of ${e.name}`, robots: 'noindex, follow', cache: 'public' });
});
router.get('/e/:slug/summary/:n', wrap(async (req, res) => {
-- const e = locate(req, res, `/summary/${req.params.n}`);
++ const e = await locate(req, res, `/summary/${req.params.n}`);
if (!e) return;
-- const r = svc.summaryRevision(e, req.params.n, req.actor);
-- send(req, res, 200, views.summaryRevisionPage(svc.entityView(e), r, { editor: isEditor(req) }), { title: `${e.name}: summary revision ${r.number}`, robots: 'noindex, follow' });
++ const r = await svc.summaryRevision(e, req.params.n, req.actor);
++ send(req, res, 200, views.summaryRevisionPage(await svc.entityView(e), r, { editor: isEditor(req) }), { title: `${e.name}: summary revision ${r.number}`, robots: 'noindex, follow' });
}));
-- router.get('/e/:slug/correct', (req, res) => {
-- const e = locate(req, res, '/correct');
++ router.get('/e/:slug/correct', async (req, res) => {
++ const e = await locate(req, res, '/correct');
if (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
-- send(req, res, 200, views.correctionPage(svc.entityView(e), { values: { target_type: req.query.target_type, target_id: req.query.target_id } }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
++ send(req, res, 200, views.correctionPage(await svc.entityView(e), { values: { target_type: req.query.target_type, target_id: req.query.target_id } }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
});
router.post('/e/:slug/correct', F('reviews.correction.submit'), form, wrap(async (req, res) => {
-- const e = locate(req, res, '/correct');
++ const e = await locate(req, res, '/correct');
if (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
const b = req.body || {};
try {
-- svc.submitCorrection(e.id, { target_type: b.target_type, target_id: b.target_id || null, body: b.body, evidence_url: b.evidence_url || null }, req.actor);
-- send(req, res, 201, views.correctionPage(svc.entityView(e), { done: true }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
++ await svc.submitCorrection(e.id, { target_type: b.target_type, target_id: b.target_id || null, body: b.body, evidence_url: b.evidence_url || null }, req.actor);
++ send(req, res, 201, views.correctionPage(await svc.entityView(e), { done: true }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
} catch (err) {
if (!err.status || err.status >= 500) throw err;
-- send(req, res, err.status, views.correctionPage(svc.entityView(e), { values: b, error: err.message }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
++ send(req, res, err.status, views.correctionPage(await svc.entityView(e), { values: b, error: err.message }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
}
}));
router.post('/e/:slug/discuss', F('reviews.discussion.comment'), form, wrap(async (req, res) => {
-- const e = locate(req, res, '/discuss');
++ const e = await locate(req, res, '/discuss');
if (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to comment.');
const message = String((req.body || {}).message || '').trim();
@@@ -243,11 +243,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}));
// ── Editors ──────────────────────────────────────────────
-- router.get('/editor', (req, res) => {
++ router.get('/editor', async (req, res) => {
if (!editorOnly(req, res)) return;
send(req, res, 200, views.editorHome({
-- queue: svc.resolutionQueue(100), pending: svc.pendingSummaries(), flagged: svc.flaggedSummaries(),
-- corrections: svc.openCorrections(), stats: svc.stats(), itemView: svc.itemView,
++ // Item views are read here (async); the view renders them as they are.
++ queue: await Promise.all((await svc.resolutionQueue(100)).map((row) => svc.itemView(row))), pending: await svc.pendingSummaries(), flagged: await svc.flaggedSummaries(),
++ corrections: await svc.openCorrections(), stats: await svc.stats(), itemView: (it) => it,
}), { title: 'Editor desk', robots: 'noindex, nofollow' });
});
@@@ -268,7 +269,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const b = req.body || {};
const aliases = ['source', 'url', 'gtin', 'sku', 'external'].map((type) => ({ type, value: String(b[`alias_${type}`] || '').trim() })).filter((a) => a.value);
try {
-- const out = svc.createEntity({ name: b.name, kind: b.kind, description: b.description || null, aliases }, req.actor);
++ const out = await svc.createEntity({ name: b.name, kind: b.kind, description: b.description || null, aliases }, req.actor);
res.redirect(303, `${svc.entityPath(out.entity)}/edit`);
} catch (err) {
if (!err.status || err.status >= 500) throw err;
@@@ -277,102 +278,102 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}));
/** An open correction request about `e` (the summary form then answers it). */
-- function openCorrectionFor(e, id) {
-- const c = id ? svc.correction(String(id)) : null;
-- return c && c.status === 'open' && svc.canonicalOf(c.entity_id) === e.id ? c : null;
++ async function openCorrectionFor(e, id) {
++ const c = id ? await svc.correction(String(id)) : null;
++ return c && c.status === 'open' && await svc.canonicalOf(c.entity_id) === e.id ? c : null;
}
-- function editPage(req, res, e, { status = 200, error = null, flash = null, correcting = null } = {}) {
-- send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, correcting, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });
++ async function editPage(req, res, e, { status = 200, error = null, flash = null, correcting = null } = {}) {
++ send(req, res, status, views.editEntityPage({ page: await svc.page(e, req.actor), error, flash, correcting, entities: (await svc.listEntities({ limit: 200 })).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });
}
-- router.get('/e/:slug/edit', (req, res) => {
++ router.get('/e/:slug/edit', async (req, res) => {
if (!editorOnly(req, res)) return;
-- const e = locate(req, res, '/edit');
++ const e = await locate(req, res, '/edit');
if (!e) return;
-- editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null, correcting: openCorrectionFor(e, req.query.correction) });
++ await editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null, correcting: await openCorrectionFor(e, req.query.correction) });
});
router.post('/e/:slug/edit', F('reviews.entity.manage'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
-- let e = locate(req, res, '/edit');
++ let e = await locate(req, res, '/edit');
if (!e) return;
const b = req.body || {};
try {
switch (b.action) {
-- case 'details': e = svc.updateEntity(e.id, { name: b.name, slug: b.slug, kind: b.kind, description: b.description || null, noindex: b.noindex === '1' }, req.actor); break;
-- case 'add_alias': svc.addAlias(e.id, { type: b.type, value: b.value }, req.actor); break;
-- case 'remove_alias': svc.removeAlias(e.id, b.alias_id, req.actor); break;
-- case 'add_link': svc.addLink(e.id, { type: b.type, to: b.to }, req.actor); break;
-- case 'unpublish_summary': svc.unpublishSummary(e.id, req.actor); break;
++ case 'details': e = await svc.updateEntity(e.id, { name: b.name, slug: b.slug, kind: b.kind, description: b.description || null, noindex: b.noindex === '1' }, req.actor); break;
++ case 'add_alias': await svc.addAlias(e.id, { type: b.type, value: b.value }, req.actor); break;
++ case 'remove_alias': await svc.removeAlias(e.id, b.alias_id, req.actor); break;
++ case 'add_link': await svc.addLink(e.id, { type: b.type, to: b.to }, req.actor); break;
++ case 'unpublish_summary': await svc.unpublishSummary(e.id, req.actor); break;
case 'trust': {
const [scope, ...rest] = String(b.scope_ref || '').split(':');
-- svc.setTrust({ scope, scope_id: rest.join(':'), key: b.key, value: b.value, note: b.note || null }, req.actor);
++ await svc.setTrust({ scope, scope_id: rest.join(':'), key: b.key, value: b.value, note: b.note || null }, req.actor);
break;
}
default: return errorPage(req, res, 400, 'Unknown action', 'The form sent an action this page does not know.');
}
-- res.redirect(303, `${svc.entityPath(svc.entityById(e.id))}/edit?saved=1`);
++ res.redirect(303, `${svc.entityPath(await svc.entityById(e.id))}/edit?saved=1`);
} catch (err) {
if (!err.status || err.status >= 500) throw err;
-- editPage(req, res, svc.entityById(e.id), { status: err.status, error: err.message });
++ await editPage(req, res, await svc.entityById(e.id), { status: err.status, error: err.message });
}
}));
router.post('/e/:slug/summary', F('reviews.summary.publish'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
-- const e = locate(req, res, '/summary');
++ const e = await locate(req, res, '/summary');
if (!e) return;
try {
-- svc.writeSummary(e.id, summaryFromForm(req.body || {}), req.actor);
++ await svc.writeSummary(e.id, summaryFromForm(req.body || {}), req.actor);
res.redirect(303, `${svc.entityPath(e)}/edit?saved=1`);
} catch (err) {
if (!err.status || err.status >= 500) throw err;
-- editPage(req, res, e, { status: err.status, error: err.message, correcting: openCorrectionFor(e, (req.body || {}).correction_id) });
++ await editPage(req, res, e, { status: err.status, error: err.message, correcting: await openCorrectionFor(e, (req.body || {}).correction_id) });
}
}));
router.post('/e/:slug/summary/:n/review', F('reviews.summary.publish'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
-- const e = locate(req, res, `/summary/${req.params.n}/review`);
++ const e = await locate(req, res, `/summary/${req.params.n}/review`);
if (!e) return;
const b = req.body || {};
-- svc.reviewSummary(e.id, req.params.n, { decision: b.decision, note: b.note || null, publish: true }, req.actor);
++ await svc.reviewSummary(e.id, req.params.n, { decision: b.decision, note: b.note || null, publish: true }, req.actor);
res.redirect(303, `${svc.entityPath(e)}?saved=1`);
}));
router.post('/e/:slug/merge', F('reviews.entity.merge'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
-- const e = locate(req, res, '/merge');
++ const e = await locate(req, res, '/merge');
if (!e) return;
const b = req.body || {};
-- const out = svc.merge(e.id, { into: String(b.into || '').trim(), note: b.note || null }, req.actor);
++ const out = await svc.merge(e.id, { into: String(b.into || '').trim(), note: b.note || null }, req.actor);
res.redirect(303, `${svc.entityPath(out.into)}/history#merges`);
}));
router.post('/e/:slug/split', F('reviews.entity.merge'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
-- const e = svc.findEntity(req.params.slug);
++ const e = await svc.findEntity(req.params.slug);
if (!e) return notFound(req, res);
const b = req.body || {};
-- const out = svc.split(e.id, { note: b.note || null }, req.actor);
++ const out = await svc.split(e.id, { note: b.note || null }, req.actor);
res.redirect(303, `${svc.entityPath(out.entity)}/history#merges`);
}));
-- router.get('/editor/items/:id', (req, res) => {
++ router.get('/editor/items/:id', async (req, res) => {
if (!editorOnly(req, res)) return;
-- const row = svc.item(req.params.id);
++ const row = await svc.item(req.params.id);
if (!row) return notFound(req, res);
-- const item = svc.itemView(row);
++ const item = await svc.itemView(row);
const candidates = item.candidates.map((c) => c.entity).filter(Boolean);
send(req, res, 200, views.itemPage({ item, candidates }), { title: `Item ${item.id}`, robots: 'noindex, nofollow' });
});
router.post('/editor/items/:id', F('reviews.item.resolve'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const b = req.body || {};
-- if (b.action === 'ignore') svc.ignoreItem(req.params.id, { note: b.note || null }, req.actor);
++ if (b.action === 'ignore') await svc.ignoreItem(req.params.id, { note: b.note || null }, req.actor);
else {
const target = String(b.entity_other || '').trim() || String(b.entity || '').trim();
-- svc.confirmResolution(req.params.id, { entity: target, add_alias: b.add_alias || null }, req.actor);
++ await svc.confirmResolution(req.params.id, { entity: target, add_alias: b.add_alias || null }, req.actor);
}
res.redirect(303, '/editor#items');
}));
@@@ -380,7 +381,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
router.post('/editor/corrections/:id', F('reviews.correction.resolve'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const b = req.body || {};
-- svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);
++ await svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);
res.redirect(303, '/editor#corrections');
}));
ddiff --git a/server/index.js b/server/index.js
iindex bc7967c..2e20517 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -17,9 +17,10 @@ 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): opened and migrated here unless the caller (a test) hands in a migrated handle.
++ const db = givenDb || await openDb(config, { log });
const stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const svc = createReviewsService({ db, stores, outbox: platform.outbox, config, now, log });
@@@ -30,7 +31,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl, limitsNow });
// Search holds whatever the current rules say (idempotent: unchanged documents are not re-sent).
-- try { svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }
++ try { await svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }
const timers = [];
if (workers) {
@@@ -55,7 +56,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
for (const t of timers) { clearInterval(t); clearTimeout(t); }
await platform.outbox.stop();
if (server) await new Promise((resolve) => server.close(resolve));
-- db.close();
++ if (!givenDb) await db.close();
}
return { app, db, svc, sync, stores, platform, keys, viewers, server, config, stop };
}
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
iindex 0152210..ebafb7d 100644
---- a/server/integrations/platform.js
++++ b/server/integrations/platform.js
@@@ -9,7 +9,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,12 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
onWarning: (msg) => log.warn(`[Reviews] ${msg}`),
});
const events = createEventsClient(sdk, { source: 'reviews' });
-- const outbox = createOutbox(db, {
++ // The PostgreSQL outbox (ADR-035): rows are written in the change's own transaction (enqueue(db, …) joins the
++ // ambient transaction); several processes relay one table safely (leases). The table is in migrations/.
++ const outbox = createPgOutbox(db, {
events, table: 'review_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,
onError: (err) => log.warn(`[Reviews] event relay: ${err && err.message}`),
});
-- outbox.ensureSchema();
async function getJson(url, audience, { headers = {}, timeoutMs = 8000 } = {}) {
let res;
ddiff --git a/server/reviews/service.js b/server/reviews/service.js
iindex cfa3a75..59ae854 100644
---- a/server/reviews/service.js
++++ b/server/reviews/service.js
@@@ -12,6 +12,7 @@
*
* Methods take an actor (server/reviews/access.js) and throw ReviewsError (status + stable code).
*/
++const { AsyncLocalStorage } = require('async_hooks');
const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
@@@ -66,11 +67,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
insertEntity: db.prepare(`INSERT INTO review_entities (id, slug, name, kind, description, created_by, created_at, updated_at)
VALUES (@id, @slug, @name, @kind, @description, @created_by, @now, @now)`),
mergedInto: db.prepare("SELECT id FROM review_entities WHERE merged_into = ? AND state = 'merged'"),
-- listActive: db.prepare(`SELECT e.* FROM review_entities e WHERE e.state = 'active' ORDER BY e.name COLLATE NOCASE LIMIT ? OFFSET ?`),
++ listActive: db.prepare(`SELECT e.* FROM review_entities e WHERE e.state = 'active' ORDER BY lower(e.name) LIMIT ? OFFSET ?`),
countActive: db.prepare("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active'"),
-- searchEntities: db.prepare(`SELECT DISTINCT e.* FROM review_entities e LEFT JOIN review_entity_aliases a ON a.entity_id = e.id AND a.removed_at IS NULL AND a.type = 'name'
-- WHERE e.state = 'active' AND (e.name LIKE @like ESCAPE '\\' OR a.norm LIKE @nlike ESCAPE '\\')
-- ORDER BY e.name COLLATE NOCASE LIMIT @limit`),
++ searchEntities: db.prepare(`SELECT e.* FROM review_entities e
++ WHERE e.state = 'active' AND (e.name ILIKE @like ESCAPE '\\'
++ OR EXISTS (SELECT 1 FROM review_entity_aliases a WHERE a.entity_id = e.id AND a.removed_at IS NULL AND a.type = 'name' AND a.norm ILIKE @nlike ESCAPE '\\'))
++ ORDER BY lower(e.name) LIMIT @limit`),
alias: db.prepare('SELECT * FROM review_entity_aliases WHERE id = ?'),
aliasesOf: db.prepare('SELECT * FROM review_entity_aliases WHERE entity_id = ? AND removed_at IS NULL ORDER BY type, norm'),
strongAlias: db.prepare("SELECT * FROM review_entity_aliases WHERE type = ? AND norm = ? AND removed_at IS NULL AND type != 'name'"),
@@@ -132,7 +134,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
revision_published_at = @now, flagged = 0, flag_reason = NULL, flagged_at = NULL, updated_at = @now WHERE id = @id`),
unpublishSummary: db.prepare("UPDATE review_summaries SET state = 'unpublished', updated_at = ? WHERE id = ?"),
flagSummary: db.prepare('UPDATE review_summaries SET flagged = 1, flag_reason = ?, flagged_at = ?, updated_at = ? WHERE id = ?'),
-- insertCitation: db.prepare('INSERT OR IGNORE INTO review_summary_citations (summary_id, revision, point, signal_id) VALUES (?, ?, ?, ?)'),
++ insertCitation: db.prepare('INSERT INTO review_summary_citations (summary_id, revision, point, signal_id) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING'),
citationsOf: db.prepare('SELECT * FROM review_summary_citations WHERE summary_id = ? AND revision = ? ORDER BY point, signal_id'),
summariesCiting: db.prepare('SELECT DISTINCT summary_id FROM review_summary_citations WHERE signal_id = ?'),
flaggedSummaries: db.prepare('SELECT * FROM review_summaries WHERE flagged = 1 ORDER BY flagged_at DESC LIMIT 200'),
@@@ -153,7 +155,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
// ── Small helpers ────────────────────────────────────────
const fail = (status, code, message, extra) => { throw new ReviewsError(status, code, message, extra); };
-- const tx = (fn) => db.transaction(fn)();
++ const tx = async (fn) => await db.tx(() => fn()); // ambient: plain db calls inside join it
const entityPath = (e) => `/e/${encodeURIComponent(e.slug)}`;
const entityUrl = (e) => seo.canonicalUrl(origin, entityPath(e));
const parse = (s, d) => { try { return s == null ? d : JSON.parse(s); } catch { return d; } };
@@@ -180,58 +182,58 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (v && typeof v === 'object' && depth < 4) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clipText(x, depth + 1)]));
return v;
}
-- function audit(actor, action, entityId, target, detail = {}) {
-- q.audit.run(now(), typeof actor === 'string' ? actor : actorId(actor), action, entityId || null, target || null, JSON.stringify(clipText(detail)));
++ async function audit(actor, action, entityId, target, detail = {}) {
++ await q.audit.run(now(), typeof actor === 'string' ? actor : actorId(actor), action, entityId || null, target || null, JSON.stringify(clipText(detail)));
}
-- function emit(envelope) { return outbox.enqueue(envelope); }
++ async function emit(envelope) { return await outbox.enqueue(db, envelope); }
-- function entityOrFail(ref) {
++ async function entityOrFail(ref) {
const s = String(ref == null ? '' : ref);
-- const e = ENTITY_ID_RE.test(s) ? q.entity.get(s) : q.entityBySlug.get(s);
++ const e = ENTITY_ID_RE.test(s) ? await q.entity.get(s) : await q.entityBySlug.get(s);
if (!e) fail(404, 'entity.not_found', 'No such entity');
return e;
}
/** The entity a merged entity now lives under (follows merged_into). */
-- function canonicalOf(entityId) {
-- let e = q.entity.get(entityId);
++ async function canonicalOf(entityId) {
++ let e = await q.entity.get(entityId);
const seen = new Set();
-- while (e && e.state === 'merged' && e.merged_into && !seen.has(e.id)) { seen.add(e.id); e = q.entity.get(e.merged_into); }
++ while (e && e.state === 'merged' && e.merged_into && !seen.has(e.id)) { seen.add(e.id); e = await q.entity.get(e.merged_into); }
return e ? e.id : null;
}
/** The entity and every entity merged into it, transitively. */
-- function closure(entityId) {
++ async function closure(entityId) {
const out = new Set([entityId]);
const stack = [entityId];
while (stack.length) {
const id = stack.pop();
-- for (const r of q.mergedInto.all(id)) if (!out.has(r.id)) { out.add(r.id); stack.push(r.id); }
++ for (const r of await q.mergedInto.all(id)) if (!out.has(r.id)) { out.add(r.id); stack.push(r.id); }
}
return out;
}
-- function activeSignalsIn(ids) {
++ async function activeSignalsIn(ids) {
const out = [];
-- for (const id of ids) out.push(...q.activeSignalsOfEntity.all(id));
++ for (const id of ids) out.push(...await q.activeSignalsOfEntity.all(id));
return out.sort((a, b) => (a.observed_at < b.observed_at ? 1 : a.observed_at > b.observed_at ? -1 : (a.id < b.id ? 1 : -1)));
}
-- function allSignalsIn(ids) {
++ async function allSignalsIn(ids) {
const out = [];
-- for (const id of ids) out.push(...q.signalsOfEntity.all(id));
++ for (const id of ids) out.push(...await q.signalsOfEntity.all(id));
return out.sort((a, b) => (a.observed_at < b.observed_at ? 1 : a.observed_at > b.observed_at ? -1 : (a.id < b.id ? 1 : -1)));
}
// ── Aggregates ───────────────────────────────────────────
-- function exclusions() {
++ async function exclusions() {
return {
-- sources: new Map(q.excludedSources.all().map((r) => [r.scope_id, r.note])),
-- signals: new Map(q.excludedSignals.all().map((r) => [r.scope_id, r.note])),
++ sources: new Map((await q.excludedSources.all()).map((r) => [r.scope_id, r.note])),
++ signals: new Map((await q.excludedSignals.all()).map((r) => [r.scope_id, r.note])),
};
}
-- function computeFor(canonicalId) {
-- return computeAggregate(activeSignalsIn(closure(canonicalId)), { exclusions: exclusions() });
++ async function computeFor(canonicalId) {
++ return computeAggregate(await activeSignalsIn(await closure(canonicalId)), { exclusions: await exclusions() });
}
/**
@@@ -239,18 +241,18 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* to nothing (every signal gone) is recorded as a revision whose result is null; an entity
* that never had a qualifying signal has no aggregate row at all.
*/
-- function refreshAggregate(entityId, trigger) {
-- const canon = canonicalOf(entityId);
-- const e = canon && q.entity.get(canon);
++ async function refreshAggregate(entityId, trigger) {
++ const canon = await canonicalOf(entityId);
++ const e = canon && await q.entity.get(canon);
if (!e || e.state !== 'active') return null;
-- const result = computeFor(canon);
++ const result = await computeFor(canon);
const hash = hashOf(result);
-- const last = q.lastAggregate.get(canon);
++ const last = await q.lastAggregate.get(canon);
if (last && last.inputs_hash === hash) return last;
if (!last && result === null) return null;
const revision = last ? last.revision + 1 : 1;
-- q.insertAggregate.run(canon, revision, now(), String(trigger).slice(0, 60), hash, result ? JSON.stringify(result) : null);
-- return q.lastAggregate.get(canon);
++ await q.insertAggregate.run(canon, revision, now(), String(trigger).slice(0, 60), hash, result ? JSON.stringify(result) : null);
++ return await q.lastAggregate.get(canon);
}
function aggregateView(row) {
@@@ -275,20 +277,20 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
}
/** For each point of a revision: which cited signals are still active and belong to the entity. */
-- function citationState(canonicalId, rev) {
-- const members = closure(canonicalId);
-- return summaryPoints(rev).map((p) => {
-- const cites = p.signals.map((id) => {
-- const s = q.signal.get(id);
++ async function citationState(canonicalId, rev) {
++ const members = await closure(canonicalId);
++ return Promise.all(summaryPoints(rev).map(async (p) => {
++ const cites = (await Promise.all(p.signals.map(async (id) => {
++ const s = await q.signal.get(id);
const ok = !!(s && s.status === 'active' && members.has(s.entity_id));
return { signal_id: id, ok, status: s ? s.status : 'missing', in_entity: !!(s && members.has(s.entity_id)), superseded_by: s ? s.superseded_by : null };
-- });
++ })));
return { ...p, cites, supported: cites.some((c) => c.ok) };
-- });
++ }));
}
-- function unsupportedCount(canonicalId, rev) {
-- return citationState(canonicalId, rev).filter((p) => !p.supported).length;
++ async function unsupportedCount(canonicalId, rev) {
++ return (await citationState(canonicalId, rev)).filter((p) => !p.supported).length;
}
function cleanPoints(list, what) {
@@@ -311,7 +313,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
}
/** Validates a summary body. Every cited signal must be active and belong to the entity. */
-- function checkSummaryInput(canonicalId, input, { ai = false } = {}) {
++ async function checkSummaryInput(canonicalId, input, { ai = false } = {}) {
if (!input || typeof input !== 'object') fail(422, 'summary.invalid', 'A summary is { overview, pros, cons }');
for (const k of Object.keys(input)) {
if (RATING_KEY_RE.test(k)) fail(422, 'summary.rating_forbidden', `${k}: a summary carries no rating; ratings come only from source signals`);
@@@ -329,28 +331,28 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (RATING_TEXT_RE.test(t)) fail(422, 'summary.rating_in_text', 'AI-drafted text states no rating or star count; the aggregate comes only from source signals');
}
}
-- const members = closure(canonicalId);
++ const members = await closure(canonicalId);
for (const id of [...overviewSignals, ...pros.flatMap((p) => p.signals), ...cons.flatMap((p) => p.signals)]) {
if (!SIGNAL_ID_RE.test(id)) fail(422, 'summary.invalid_citation', `${id} is not a signal id`);
-- const s = q.signal.get(id);
++ const s = await q.signal.get(id);
if (!s || !members.has(s.entity_id)) fail(422, 'summary.invalid_citation', `Signal ${id} does not belong to this entity`);
if (s.status !== 'active') fail(422, 'summary.invalid_citation', `Signal ${id} is ${s.status}; cite the current signal`);
}
return { content: overview, fields: { pros, cons, overview_signals: overviewSignals } };
}
-- function ensureSummary(entityId) {
-- let s = q.summary.get(entityId);
++ async function ensureSummary(entityId) {
++ let s = await q.summary.get(entityId);
if (!s) {
const t = now();
-- q.insertSummary.run(`sum_${ulid(t)}`, entityId, t, t);
-- s = q.summary.get(entityId);
++ await q.insertSummary.run(`sum_${ulid(t)}`, entityId, t, t);
++ s = await q.summary.get(entityId);
}
return s;
}
-- function writeCitations(summary, rev) {
-- for (const p of summaryPoints(rev)) for (const id of p.signals) q.insertCitation.run(summary.id, rev.number, p.key, id);
++ async function writeCitations(summary, rev) {
++ for (const p of summaryPoints(rev)) for (const id of p.signals) await q.insertCitation.run(summary.id, rev.number, p.key, id);
}
function expectedOf(input, head) {
@@@ -359,9 +361,9 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return head;
}
-- function createRevision(args) {
++ async function createRevision(args) {
try {
-- return revisions.create(args).revision;
++ return (await revisions.create(args)).revision;
} catch (err) {
if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
throw err;
@@@ -404,41 +406,41 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* forward. An open correction request it answers is accepted in the same transaction; what the
* request said and who sent it stay with the editors.
*/
-- function correctSummary(e, input, { note, requestId = null, resolutionNote = null }, actor, who) {
++ async function correctSummary(e, input, { note, requestId = null, resolutionNote = null }, actor, who) {
const text = checkCorrectionNote(note);
-- const summary = q.summary.get(e.id);
++ const summary = await q.summary.get(e.id);
if (!summary || summary.state !== 'published' || !summary.published_revision) fail(409, 'summary.not_published', 'Only a published summary is corrected; save and publish a revision instead');
let request = null;
if (requestId != null && String(requestId).trim() !== '') {
-- request = q.correction.get(String(requestId).trim());
++ request = await q.correction.get(String(requestId).trim());
if (!request) fail(404, 'correction.not_found', 'No such correction');
-- if (canonicalOf(request.entity_id) !== e.id) fail(409, 'correction.other_entity', 'That correction is about another entity');
++ if (await canonicalOf(request.entity_id) !== e.id) fail(409, 'correction.other_entity', 'That correction is about another entity');
if (request.status !== 'open') fail(409, 'correction.closed', `Already ${request.status}`);
}
-- const pub = revisions.get(summary.id, summary.published_revision);
-- const body = checkSummaryInput(e.id, BODY_KEYS.some((k) => input[k] !== undefined) ? input : carryForward(pub));
-- const revision = createRevision({
-- entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
++ const pub = await revisions.get(summary.id, summary.published_revision);
++ const body = await checkSummaryInput(e.id, BODY_KEYS.some((k) => input[k] !== undefined) ? input : carryForward(pub));
++ const revision = await createRevision({
++ entityId: summary.id, expectedRevision: expectedOf(input, await revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
meta: {
authorship: correctionAuthorship(pub.meta && pub.meta.authorship, who),
correction: { note: text, request: request ? request.id : null, corrects: pub.number },
},
author: who, message: input.message ? String(input.message).slice(0, 500) : `Correction: ${text.slice(0, 200)}`, allowUnchanged: true,
});
-- writeCitations(summary, revision);
-- reviews.record({ entityId: summary.id, revision: revision.number, reviewer: who, decision: 'approved', note: 'correction' });
++ await writeCitations(summary, revision);
++ await reviews.record({ entityId: summary.id, revision: revision.number, reviewer: who, decision: 'approved', note: 'correction' });
if (request) {
-- q.resolveCorrection.run('accepted', who, resolutionNote, now(), request.id);
-- audit(actor, 'correction.accepted', e.id, request.id, { note: resolutionNote, summary_revision: revision.number });
++ await q.resolveCorrection.run('accepted', who, resolutionNote, now(), request.id);
++ await audit(actor, 'correction.accepted', e.id, request.id, { note: resolutionNote, summary_revision: revision.number });
}
-- audit(actor, 'summary.corrected', e.id, summary.id, { revision: revision.number, corrects: pub.number, correction_id: request ? request.id : null });
-- svc.publishSummary(e.id, { revision: revision.number }, actor);
-- const fresh = q.summary.get(e.id);
-- return { summary: fresh, revision: summaryRevisionView(fresh, revisions.get(summary.id, revision.number), e.id), published: true, correction: request ? q.correction.get(request.id) : null };
++ await audit(actor, 'summary.corrected', e.id, summary.id, { revision: revision.number, corrects: pub.number, correction_id: request ? request.id : null });
++ await svc.publishSummary(e.id, { revision: revision.number }, actor);
++ const fresh = await q.summary.get(e.id);
++ return { summary: fresh, revision: await summaryRevisionView(fresh, await revisions.get(summary.id, revision.number), e.id), published: true, correction: request ? await q.correction.get(request.id) : null };
}
-- function revisionStatus(summary, rev) {
-- const review = reviews.latest(summary.id, rev.number);
++ async function revisionStatus(summary, rev) {
++ const review = await reviews.latest(summary.id, rev.number);
const rec = rev.meta && rev.meta.authorship;
if (summary.state === 'published' && summary.published_revision === rev.number) return 'published';
if (review && review.decision === 'rejected') return 'rejected';
@@@ -452,36 +454,36 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* What a reader may see of a summary's history: revisions up to the published one while the
* summary is published, never a rejected revision or an AI draft no person approved.
*/
-- function publicRevision(summary, rev) {
++ async function publicRevision(summary, rev) {
if (!summary || summary.state !== 'published' || !summary.published_revision || rev.number > summary.published_revision) return false;
-- const review = reviews.latest(summary.id, rev.number);
++ const review = await reviews.latest(summary.id, rev.number);
if (review && review.decision === 'rejected') return false;
const rec = rev.meta && rev.meta.authorship;
return !(rec && !authorship.canPublish(rec, review).ok);
}
-- function pendingRevisions(summary) {
-- const head = revisions.headNumber(summary.id);
++ async function pendingRevisions(summary) {
++ const head = await revisions.headNumber(summary.id);
const out = [];
for (let n = (summary.published_revision || 0) + 1; n <= head; n++) {
-- const rev = revisions.get(summary.id, n);
-- const st = revisionStatus(summary, rev);
++ const rev = await revisions.get(summary.id, n);
++ const st = await revisionStatus(summary, rev);
if (st !== 'rejected') out.push({ rev, status: st });
}
return out;
}
// ── The gate, the Search document ────────────────────────
-- function publishedSummaryRevision(entityId) {
-- const s = q.summary.get(entityId);
++ async function publishedSummaryRevision(entityId) {
++ const s = await q.summary.get(entityId);
if (!s || s.state !== 'published' || !s.published_revision) return { summary: s || null, rev: null };
-- return { summary: s, rev: revisions.get(s.id, s.published_revision) };
++ return { summary: s, rev: await revisions.get(s.id, s.published_revision) };
}
-- function gateFacts(e) {
-- const members = closure(e.id);
-- const active = activeSignalsIn(members);
-- const { summary, rev } = publishedSummaryRevision(e.id);
++ async function gateFacts(e) {
++ const members = await closure(e.id);
++ const active = await activeSignalsIn(members);
++ const { summary, rev } = await publishedSummaryRevision(e.id);
const rec = rev && rev.meta && rev.meta.authorship;
return {
state: e.state === 'deleted' ? 'deleted' : 'published',
@@@ -489,29 +491,29 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
canonicalUrl: entityUrl(e),
wordCount: rev ? ssr.wordCount(summaryText(rev)) : 0,
citationCount: active.length,
-- unsupportedClaims: rev ? unsupportedCount(e.id, rev) : 0,
++ unsupportedClaims: rev ? await unsupportedCount(e.id, rev) : 0,
noindex: !!e.noindex,
-- ...(rec ? authorship.gateFacts(rec, reviews.latest(summary.id, rev.number)) : {}),
++ ...(rec ? authorship.gateFacts(rec, await reviews.latest(summary.id, rev.number)) : {}),
};
}
-- function decide(e) {
-- return seo.evaluate(gateFacts(e), { policy, now: now() });
++ async function decide(e) {
++ return seo.evaluate(await gateFacts(e), { policy, now: now() });
}
/** Sends Search the entity's current document (or a tombstone) when it differs from the last one. */
-- function syncEntity(entityId) {
-- const e = q.entity.get(entityId);
++ async function syncEntity(entityId) {
++ const e = await q.entity.get(entityId);
if (!e) return null;
const live = e.state === 'active';
-- const decision = live ? decide(e) : null;
-- const before = sequencer.current('reviews', 'entity', e.id);
-- const { rev } = live ? publishedSummaryRevision(e.id) : { rev: null };
-- const agg = live ? aggregateView(q.lastAggregate.get(e.id)) : null;
-- const signals = live ? activeSignalsIn(closure(e.id)) : [];
++ const decision = live ? await decide(e) : null;
++ const before = await sequencer.current('reviews', 'entity', e.id);
++ const { rev } = live ? await publishedSummaryRevision(e.id) : { rev: null };
++ const agg = live ? aggregateView(await q.lastAggregate.get(e.id)) : null;
++ const signals = live ? await activeSignalsIn(await closure(e.id)) : [];
const lines = [];
if (agg && agg.result) lines.push(...agg.result.computation);
-- const doc = sequencer.stamp(hooks.buildIndexDocument({
++ const doc = await sequencer.stamp(db, hooks.buildIndexDocument({
owner: 'reviews', type: 'entity', id: e.id, revision: 0,
state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',
deleted: !live || !decision || !decision.listable,
@@@ -524,52 +526,48 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
decision,
publishedAt: e.created_at, updatedAt: e.updated_at,
}));
-- if (doc.revision !== before) emit(hooks.indexEvent({ document: doc, now: now() }));
++ if (doc.revision !== before) await emit(hooks.indexEvent({ document: doc, now: now() }));
return doc;
}
-- function touchEntity(entityId) {
-- db.prepare('UPDATE review_entities SET updated_at = ? WHERE id = ?').run(now(), entityId);
++ async function touchEntity(entityId) {
++ await db.prepare('UPDATE review_entities SET updated_at = ? WHERE id = ?').run(now(), entityId);
}
// Inside batch() (a sync page, an alias that settles many items) the aggregate of each touched
// entity is recomputed once at the end: one editorial action or one Sources page → at most one
// new aggregate revision per entity.
-- let batchDepth = 0;
-- const dirty = new Map();
-- function flushEntity(canon, trigger) {
-- refreshAggregate(canon, trigger);
-- touchEntity(canon);
-- syncEntity(canon);
-- }
-- function batch(fn) {
-- return tx(() => {
-- batchDepth++;
-- try {
-- return fn();
-- } finally {
-- batchDepth--;
-- if (batchDepth === 0) {
-- const list = [...dirty];
-- dirty.clear();
-- for (const [canon, trigger] of list) flushEntity(canon, trigger);
-- }
-- }
++ // Per batch (AsyncLocalStorage): concurrent requests each keep their own dirty set, and a batch inside a batch
++ // leaves the flush to the outermost one.
++ const batchScope = new AsyncLocalStorage();
++ async function flushEntity(canon, trigger) {
++ await refreshAggregate(canon, trigger);
++ await touchEntity(canon);
++ await syncEntity(canon);
++ }
++ async function batch(fn) {
++ if (batchScope.getStore()) return await fn();
++ const dirty = new Map();
++ return await tx(async () => {
++ const out = await batchScope.run(dirty, fn);
++ for (const [canon, trigger] of dirty) await flushEntity(canon, trigger);
++ return out;
});
}
/** After signals of an entity changed: next aggregate revision, Search document. */
-- function afterSignalChange(entityId, trigger) {
-- const canon = canonicalOf(entityId);
++ async function afterSignalChange(entityId, trigger) {
++ const canon = await canonicalOf(entityId);
if (!canon) return;
-- if (batchDepth > 0) { if (!dirty.has(canon)) dirty.set(canon, trigger); return; }
-- flushEntity(canon, trigger);
++ const dirty = batchScope.getStore();
++ if (dirty) { if (!dirty.has(canon)) dirty.set(canon, trigger); return; }
++ await flushEntity(canon, trigger);
}
// ── Events ───────────────────────────────────────────────
-- function signalPayload(s) {
++ async function signalPayload(s) {
const p = {
-- signal_id: s.id, entity_id: s.entity_id, canonical_entity_id: canonicalOf(s.entity_id), type: s.type,
++ signal_id: s.id, entity_id: s.entity_id, canonical_entity_id: await canonicalOf(s.entity_id), type: s.type,
source_key: s.source_key, source_item_id: s.source_item_id, item_revision: s.item_revision,
observed_at: s.observed_at, source_published_at: s.source_published_at, canonical_url: s.canonical_url, license_note: s.license_note,
};
@@@ -578,17 +576,17 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (s.type === 'rating' || s.type === 'rating_aggregate') { p.rating_value = s.rating_value; p.rating_best = s.rating_best; p.rating_worst = s.rating_worst; p.rating_count = s.rating_count; }
return p;
}
-- function signalEvent(type, s, actor, extra = {}) {
-- emit({
++ async function signalEvent(type, s, actor, extra = {}) {
++ await emit({
event_type: type,
actor: hooks.subjectRef(actorId(actor)),
subject: { type: 'signal', id: s.id },
visibility: 'public',
-- payload: { ...signalPayload(s), ...extra },
++ payload: { ...await signalPayload(s), ...extra },
});
}
-- function entityEvent(type, e, actor, payload) {
-- emit({ event_type: type, actor: hooks.subjectRef(actorId(actor)), subject: { type: 'entity', id: e.id }, visibility: 'public', payload });
++ async function entityEvent(type, e, actor, payload) {
++ await emit({ event_type: type, actor: hooks.subjectRef(actorId(actor)), subject: { type: 'entity', id: e.id }, visibility: 'public', payload });
}
// ── Summary flags when cited signals leave ───────────────
@@@ -599,71 +597,71 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* left without a live citation are dropped. The pending revision is published only after an
* editor approves it; until then the published text stays up with the flag shown next to it.
*/
-- function flagSummary(summaryId, reason, signalIds) {
-- const s = q.summaryById.get(summaryId);
++ async function flagSummary(summaryId, reason, signalIds) {
++ const s = await q.summaryById.get(summaryId);
if (!s) return null;
-- const canon = canonicalOf(s.entity_id);
-- const e = q.entity.get(s.entity_id);
++ const canon = await canonicalOf(s.entity_id);
++ const e = await q.entity.get(s.entity_id);
if (!e || e.state !== 'active') return null;
-- const headN = revisions.headNumber(s.id);
++ const headN = await revisions.headNumber(s.id);
if (!headN) return null;
-- const head = revisions.get(s.id, headN);
++ const head = await revisions.get(s.id, headN);
const base = s.published_revision && !(headN > s.published_revision && head.meta && head.meta.system)
-- ? revisions.get(s.id, s.published_revision) : head;
-- const state = citationState(canon, base);
++ ? await revisions.get(s.id, s.published_revision) : head;
++ const state = await citationState(canon, base);
if (state.every((p) => p.cites.every((c) => c.ok))) return null;
-- const members = closure(canon);
-- const carry = (id) => {
-- let sig = q.signal.get(id);
++ const members = await closure(canon);
++ const carry = async (id) => {
++ let sig = await q.signal.get(id);
const seen = new Set();
-- while (sig && sig.status === 'superseded' && sig.superseded_by && !seen.has(sig.id)) { seen.add(sig.id); sig = q.signal.get(sig.superseded_by); }
++ while (sig && sig.status === 'superseded' && sig.superseded_by && !seen.has(sig.id)) { seen.add(sig.id); sig = await q.signal.get(sig.superseded_by); }
return sig && sig.status === 'active' && members.has(sig.entity_id) ? sig.id : null;
};
-- const mapPoints = (list) => (list || []).map((p) => ({ text: p.text, signals: [...new Set(p.signals.map(carry).filter(Boolean))] }));
++ const mapPoints = (list) => Promise.all((list || []).map(async (p) => ({ text: p.text, signals: [...new Set((await Promise.all(p.signals.map(carry))).filter(Boolean))] })));
const f = base.fields || {};
-- const pros = mapPoints(f.pros);
-- const cons = mapPoints(f.cons);
-- const overviewSignals = [...new Set((f.overview_signals || []).map(carry).filter(Boolean))];
++ const pros = await mapPoints(f.pros);
++ const cons = await mapPoints(f.cons);
++ const overviewSignals = [...new Set((await Promise.all((f.overview_signals || []).map(carry))).filter(Boolean))];
const dropped = [...pros, ...cons].filter((p) => !p.signals.length).map((p) => p.text);
const t = now();
-- q.flagSummary.run(String(reason).slice(0, 300), t, t, s.id);
-- const { revision } = revisions.create({
++ await q.flagSummary.run(String(reason).slice(0, 300), t, t, s.id);
++ const { revision } = await revisions.create({
entityId: s.id, expectedRevision: headN, content: base.content,
fields: { pros: pros.filter((p) => p.signals.length), cons: cons.filter((p) => p.signals.length), overview_signals: overviewSignals },
meta: { ...(base.meta && base.meta.authorship ? { authorship: base.meta.authorship } : {}), system: { reason: String(reason).slice(0, 300), signals: signalIds, base_revision: base.number, dropped_points: dropped } },
author: 'svc:reviews', message: `Pending: ${reason}`, allowUnchanged: true,
});
-- writeCitations(s, revision);
-- audit('svc:reviews', 'summary.flagged', s.entity_id, s.id, { reason, signals: signalIds, pending_revision: revision.number, dropped_points: dropped.length });
-- syncEntity(canon);
++ await writeCitations(s, revision);
++ await audit('svc:reviews', 'summary.flagged', s.entity_id, s.id, { reason, signals: signalIds, pending_revision: revision.number, dropped_points: dropped.length });
++ await syncEntity(canon);
return revision;
}
-- function flagSummariesCiting(signalIds, reason) {
++ async function flagSummariesCiting(signalIds, reason) {
const ids = new Set();
-- for (const sid of signalIds) for (const r of q.summariesCiting.all(sid)) ids.add(r.summary_id);
-- for (const id of ids) flagSummary(id, reason, signalIds);
++ for (const sid of signalIds) for (const r of await q.summariesCiting.all(sid)) ids.add(r.summary_id);
++ for (const id of ids) await flagSummary(id, reason, signalIds);
}
// ── Signals ──────────────────────────────────────────────
-- function withdrawSignal(sig, reason, actor, { status = 'withdrawn', supersededBy = null } = {}) {
-- const r = q.setSignalStatus.run({ id: sig.id, status, reason: String(reason).slice(0, 500), at: now(), superseded_by: supersededBy });
++ async function withdrawSignal(sig, reason, actor, { status = 'withdrawn', supersededBy = null } = {}) {
++ const r = await q.setSignalStatus.run({ id: sig.id, status, reason: String(reason).slice(0, 500), at: now(), superseded_by: supersededBy });
if (!r.changes) return false;
-- signalEvent('reviews.signal.removed', q.signal.get(sig.id), actor, { status, reason: String(reason).slice(0, 500), ...(supersededBy ? { replaced_by: supersededBy } : {}) });
++ await signalEvent('reviews.signal.removed', await q.signal.get(sig.id), actor, { status, reason: String(reason).slice(0, 500), ...(supersededBy ? { replaced_by: supersededBy } : {}) });
return true;
}
/** A new signal for an item revision; the item's previous active signal is superseded. */
-- function createSignal(itemRow, entityId, sig, actor) {
-- const prev = q.activeSignalOfItem.get(itemRow.id);
++ async function createSignal(itemRow, entityId, sig, actor) {
++ const prev = await q.activeSignalOfItem.get(itemRow.id);
if (prev && prev.item_revision === itemRow.item_revision && prev.entity_id === entityId) return { signal: prev, created: false };
const t = now();
const id = `sig_${ulid(t)}`;
if (prev) {
const why = prev.entity_id !== entityId ? 'reattributed by an editor' : `source item revised (r${prev.item_revision} → r${itemRow.item_revision})`;
-- withdrawSignal(prev, why, actor, prev.entity_id !== entityId ? {} : { status: 'superseded', supersededBy: id });
++ await withdrawSignal(prev, why, actor, prev.entity_id !== entityId ? {} : { status: 'superseded', supersededBy: id });
}
-- q.insertSignal.run({
++ await q.insertSignal.run({
id, entity_id: entityId, source_item_id: itemRow.id, source_key: itemRow.source_key, item_revision: itemRow.item_revision,
type: sig.type, recommended: sig.recommended == null ? null : sig.recommended,
positive_count: sig.positive_count == null ? null : sig.positive_count, total_count: sig.total_count == null ? null : sig.total_count,
@@@ -672,13 +670,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
observed_at: itemRow.retrieved_at, source_published_at: itemRow.published_at, canonical_url: itemRow.canonical_url,
license_note: itemRow.license_note, trust: JSON.stringify(sig.trust || {}), created_by: actorId(actor), now: t,
});
-- const signal = q.signal.get(id);
-- signalEvent('reviews.signal.added', signal, actor, prev ? { replaces: prev.id } : {});
++ const signal = await q.signal.get(id);
++ await signalEvent('reviews.signal.added', signal, actor, prev ? { replaces: prev.id } : {});
if (prev) {
-- flagSummariesCiting([prev.id], prev.entity_id !== entityId ? 'A cited signal was reattributed to another entity' : 'A cited signal was replaced by a newer revision of its source item');
-- if (canonicalOf(prev.entity_id) !== canonicalOf(entityId)) afterSignalChange(prev.entity_id, 'signal_reattributed');
++ await flagSummariesCiting([prev.id], prev.entity_id !== entityId ? 'A cited signal was reattributed to another entity' : 'A cited signal was replaced by a newer revision of its source item');
++ if (await canonicalOf(prev.entity_id) !== await canonicalOf(entityId)) await afterSignalChange(prev.entity_id, 'signal_reattributed');
}
-- afterSignalChange(entityId, prev ? 'signal_replaced' : 'signal_added');
++ await afterSignalChange(entityId, prev ? 'signal_replaced' : 'signal_added');
return { signal, created: true, replaced: prev ? prev.id : null };
}
@@@ -690,15 +688,15 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* give candidates, and a name is never enough on its own → ambiguous (an editor confirms).
* Nothing → unmatched (an editor creates or picks the entity).
*/
-- function resolveIdentifiers({ strong = [], names = [] }) {
++ async function resolveIdentifiers({ strong = [], names = [] }) {
const hits = [];
for (const id of strong) {
const n = norm.tryAlias(id.type, id.value);
if (!n) continue;
-- const a = q.strongAlias.get(id.type, n);
++ const a = await q.strongAlias.get(id.type, n);
if (a) {
-- const e = q.entity.get(a.entity_id);
-- if (e && e.state !== 'deleted') hits.push({ rule: id.type, alias_id: a.id, entity_id: a.entity_id, canonical: canonicalOf(a.entity_id) });
++ const e = await q.entity.get(a.entity_id);
++ if (e && e.state !== 'deleted') hits.push({ rule: id.type, alias_id: a.id, entity_id: a.entity_id, canonical: await canonicalOf(a.entity_id) });
}
}
const canon = [...new Set(hits.map((h) => h.canonical))];
@@@ -708,9 +706,9 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
for (const name of names) {
const n = norm.tryAlias('name', name);
if (!n) continue;
-- for (const a of q.nameAliases.all(n)) {
-- const c = canonicalOf(a.entity_id);
-- const e = c && q.entity.get(c);
++ for (const a of await q.nameAliases.all(n)) {
++ const c = await canonicalOf(a.entity_id);
++ const e = c && await q.entity.get(c);
if (e && e.state === 'active' && !named.some((x) => x.entity_id === c)) named.push({ entity_id: c, rule: 'name' });
}
}
@@@ -722,37 +720,37 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return extract.identifiers({ source_key: row.source_key, canonical_url: row.canonical_url, kind: row.kind, title: row.title, fields: parse(row.fields, {}) });
}
-- function setItemResolution(row, r, by) {
-- q.setResolution.run({ id: row.id, resolution: r.resolution, entity_id: r.entity_id, rule: r.rule, candidates: JSON.stringify(r.candidates || []), by, at: r.resolution === 'resolved' ? now() : null, now: now() });
-- return q.item.get(row.id);
++ async function setItemResolution(row, r, by) {
++ await q.setResolution.run({ id: row.id, resolution: r.resolution, entity_id: r.entity_id, rule: r.rule, candidates: JSON.stringify(r.candidates || []), by, at: r.resolution === 'resolved' ? now() : null, now: now() });
++ return await q.item.get(row.id);
}
/** Create/replace/withdraw the item's signal to match its current content and resolution. */
-- function reconcileItemSignal(row, actor) {
-- const active = q.activeSignalOfItem.get(row.id);
++ async function reconcileItemSignal(row, actor) {
++ const active = await q.activeSignalOfItem.get(row.id);
if (row.state !== 'active' || row.resolution !== 'resolved') {
if (active) {
-- withdrawSignal(active, row.state !== 'active' ? `removed by its source: ${row.removed_reason || 'no reason given'}` : 'the item is no longer attributed to this entity', actor);
-- flagSummariesCiting([active.id], row.state !== 'active' ? 'A cited signal was withdrawn: its source removed the item' : 'A cited signal was withdrawn');
-- afterSignalChange(active.entity_id, 'signal_withdrawn');
++ await withdrawSignal(active, row.state !== 'active' ? `removed by its source: ${row.removed_reason || 'no reason given'}` : 'the item is no longer attributed to this entity', actor);
++ await flagSummariesCiting([active.id], row.state !== 'active' ? 'A cited signal was withdrawn: its source removed the item' : 'A cited signal was withdrawn');
++ await afterSignalChange(active.entity_id, 'signal_withdrawn');
}
return { signal: null };
}
const { signal } = extract.extractSignal({ kind: row.kind, fields: parse(row.fields, {}) });
if (!signal) {
if (active) {
-- withdrawSignal(active, `the source no longer states a signal (${row.signal_note || 'no value'})`, actor);
-- flagSummariesCiting([active.id], 'A cited signal was withdrawn: its source no longer states it');
-- afterSignalChange(active.entity_id, 'signal_withdrawn');
++ await withdrawSignal(active, `the source no longer states a signal (${row.signal_note || 'no value'})`, actor);
++ await flagSummariesCiting([active.id], 'A cited signal was withdrawn: its source no longer states it');
++ await afterSignalChange(active.entity_id, 'signal_withdrawn');
}
return { signal: null };
}
-- return createSignal(row, row.entity_id, signal, actor);
++ return await createSignal(row, row.entity_id, signal, actor);
}
-- function upsertSource(key, prov, info) {
++ async function upsertSource(key, prov, info) {
const t = now();
-- const existing = q.source.get(key);
++ const existing = await q.source.get(key);
const values = {
key, name: info && info.name ? String(info.name).slice(0, 300) : null,
homepage_url: info && info.homepage_url ? String(info.homepage_url).slice(0, 1000) : null,
@@@ -764,8 +762,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
last_success_at: info && info.last_success_at ? String(info.last_success_at) : null,
now: t,
};
-- if (existing) q.updateSource.run(values); else q.insertSource.run(values);
-- return q.source.get(key);
++ if (existing) await q.updateSource.run(values); else await q.insertSource.run(values);
++ return await q.source.get(key);
}
function checkItem(item) {
@@@ -784,21 +782,21 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* nothing but the last-seen time. Synchronous (runs inside the caller's transaction).
* → { outcome, item, signal }
*/
-- function applyItem(item, { sourceInfo = null, actor = { kind: 'system', service: 'svc:reviews' } } = {}) {
++ async function applyItem(item, { sourceInfo = null, actor = { kind: 'system', service: 'svc:reviews' } } = {}) {
checkItem(item);
if (item.category && item.category !== 'reviews') return { outcome: 'ignored:category', item: null, signal: null };
const prov = item.provenance;
const t = now();
-- return tx(() => {
-- upsertSource(item.source_key, prov, sourceInfo);
-- let row = q.item.get(item.id);
++ return await tx(async () => {
++ await upsertSource(item.source_key, prov, sourceInfo);
++ let row = await q.item.get(item.id);
const retrievedAt = new Date(prov.retrieved_at).toISOString();
if (item.removed) {
const reason = String((item.removed && item.removed.reason) || 'removed').slice(0, 500);
if (!row) {
// Known only as removed: kept for the record, never a signal.
const fields = extract.keptFields(item);
-- q.insertItem.run({
++ await q.insertItem.run({
id: item.id, source_key: item.source_key, kind: String(item.kind || 'unknown').slice(0, 40), identity: String(item.identity || item.id).slice(0, 2048),
canonical_url: item.canonical_url || null, title: extract.keptTitle(item), item_revision: item.revision, content_hash: prov.content_hash,
published_at: item.published_at || null, retrieved_at: retrievedAt, parser_version: prov.parser_version || null,
@@@ -806,19 +804,19 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
state: 'removed', removed_at: item.removed.at || retrievedAt, removed_reason: reason, resolution: 'unmatched', entity_id: null,
resolution_rule: null, candidates: '[]', resolved_by: null, resolved_at: null, signal_note: 'removed by its source before Reviews read it', now: t,
});
-- return { outcome: 'removed:unknown', item: q.item.get(item.id), signal: null };
++ return { outcome: 'removed:unknown', item: await q.item.get(item.id), signal: null };
}
if (row.state === 'removed') return { outcome: 'unchanged', item: row, signal: null };
-- q.removeItem.run(item.removed.at || retrievedAt, reason, t, row.id);
-- row = q.item.get(row.id);
-- reconcileItemSignal(row, actor);
++ await q.removeItem.run(item.removed.at || retrievedAt, reason, t, row.id);
++ row = await q.item.get(row.id);
++ await reconcileItemSignal(row, actor);
return { outcome: 'removed', item: row, signal: null };
}
if (row && row.state === 'removed') return { outcome: 'ignored:removed', item: row, signal: null };
if (row && item.revision < row.item_revision) return { outcome: 'ignored:older_revision', item: row, signal: null };
if (row && item.revision === row.item_revision && row.content_hash === prov.content_hash) {
-- q.touchItem.run(retrievedAt, t, row.id, retrievedAt);
-- return { outcome: 'unchanged', item: q.item.get(row.id), signal: q.activeSignalOfItem.get(row.id) || null };
++ await q.touchItem.run(retrievedAt, t, row.id, retrievedAt);
++ return { outcome: 'unchanged', item: await q.item.get(row.id), signal: await q.activeSignalOfItem.get(row.id) || null };
}
const fields = extract.keptFields(item);
const { note } = extract.extractSignal(item);
@@@ -830,38 +828,38 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
};
let outcome;
if (!row) {
-- q.insertItem.run({ ...values, state: 'active', removed_at: null, removed_reason: null, resolution: 'unmatched', entity_id: null, resolution_rule: null, candidates: '[]', resolved_by: null, resolved_at: null });
++ await q.insertItem.run({ ...values, state: 'active', removed_at: null, removed_reason: null, resolution: 'unmatched', entity_id: null, resolution_rule: null, candidates: '[]', resolved_by: null, resolved_at: null });
outcome = 'created';
} else {
-- q.updateItemContent.run(values);
++ await q.updateItemContent.run(values);
outcome = 'updated';
}
-- row = q.item.get(item.id);
-- if (row.resolution === 'unmatched' || row.resolution === 'ambiguous') row = setItemResolution(row, resolveIdentifiers(itemIdentifiers(row)), 'rule');
-- const out = reconcileItemSignal(row, actor);
-- return { outcome, item: q.item.get(item.id), signal: out.signal || null };
++ row = await q.item.get(item.id);
++ if (row.resolution === 'unmatched' || row.resolution === 'ambiguous') row = await setItemResolution(row, await resolveIdentifiers(itemIdentifiers(row)), 'rule');
++ const out = await reconcileItemSignal(row, actor);
++ return { outcome, item: await q.item.get(item.id), signal: out.signal || null };
});
}
/** New aliases may settle items that waited: re-run the rules for every unresolved item. */
-- function reresolvePending(actor) {
-- return batch(() => {
++ async function reresolvePending(actor) {
++ return await batch(async () => {
let settled = 0;
-- for (const row of q.unresolvedItems.all()) {
-- const r = resolveIdentifiers(itemIdentifiers(row));
++ for (const row of await q.unresolvedItems.all()) {
++ const r = await resolveIdentifiers(itemIdentifiers(row));
if (r.resolution === row.resolution && JSON.stringify(r.candidates) === row.candidates) continue;
-- const fresh = setItemResolution(row, r, 'rule');
-- if (fresh.resolution === 'resolved') { reconcileItemSignal(fresh, actor); settled++; }
++ const fresh = await setItemResolution(row, r, 'rule');
++ if (fresh.resolution === 'resolved') { await reconcileItemSignal(fresh, actor); settled++; }
}
return settled;
});
}
// ── Entities ─────────────────────────────────────────────
-- function uniqueSlug(base) {
++ async function uniqueSlug(base) {
const root = norm.slug(base) || 'entity';
let s = root;
-- for (let i = 2; q.entityBySlug.get(s) || redirects.resolve(`/e/${s}`, { currentPath: () => null }); i++) s = `${root}-${i}`.slice(0, 90);
++ for (let i = 2; await q.entityBySlug.get(s) || await redirects.resolve(`/e/${s}`, { currentPath: () => null }); i++) s = `${root}-${i}`.slice(0, 90);
return s;
}
@@@ -873,46 +871,46 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return t;
}
-- function insertAlias(entityId, type, value, actor) {
++ async function insertAlias(entityId, type, value, actor) {
if (!ALIAS_TYPES.includes(type)) fail(422, 'alias.invalid', `alias type is one of ${ALIAS_TYPES.join(', ')}`);
let n;
try { n = norm.alias(type, value); } catch (err) { fail(422, 'alias.invalid', err.message); }
if (type !== 'name') {
-- const taken = q.strongAlias.get(type, n);
++ const taken = await q.strongAlias.get(type, n);
if (taken) {
if (taken.entity_id === entityId) return taken;
-- const other = q.entity.get(taken.entity_id);
++ const other = await q.entity.get(taken.entity_id);
fail(409, 'alias.taken', `${type} ${value} already identifies "${other ? other.name : taken.entity_id}"`, { entity_id: taken.entity_id });
}
-- } else if (q.nameAliases.all(n).some((a) => a.entity_id === entityId)) {
-- return q.nameAliases.all(n).find((a) => a.entity_id === entityId);
++ } else if ((await q.nameAliases.all(n)).some((a) => a.entity_id === entityId)) {
++ return (await q.nameAliases.all(n)).find((a) => a.entity_id === entityId);
}
const t = now();
const id = `als_${ulid(t)}`;
-- q.insertAlias.run({ id, entity_id: entityId, type, value: String(value).trim().slice(0, 1000), norm: n, by: actorId(actor), now: t });
-- return q.alias.get(id);
++ await q.insertAlias.run({ id, entity_id: entityId, type, value: String(value).trim().slice(0, 1000), norm: n, by: actorId(actor), now: t });
++ return await q.alias.get(id);
}
-- function entityView(e) {
++ async function entityView(e) {
return {
id: e.id, slug: e.slug, name: e.name, kind: e.kind, description: e.description, state: e.state,
-- merged_into: e.merged_into, canonical_id: canonicalOf(e.id), noindex: !!e.noindex,
++ merged_into: e.merged_into, canonical_id: await canonicalOf(e.id), noindex: !!e.noindex,
url: entityUrl(e), created_at: toIso(e.created_at), updated_at: toIso(e.updated_at),
};
}
-- function trustMap(scope, id) {
++ async function trustMap(scope, id) {
const out = {};
-- for (const r of q.trustCurrent.all(scope, id)) out[r.key] = { value: r.value, note: r.note, set_at: toIso(r.set_at) };
++ for (const r of await q.trustCurrent.all(scope, id)) out[r.key] = { value: r.value, note: r.note, set_at: toIso(r.set_at) };
return out;
}
-- function signalView(s) {
-- const item = q.item.get(s.source_item_id);
-- const src = q.source.get(s.source_key);
++ async function signalView(s) {
++ const item = await q.item.get(s.source_item_id);
++ const src = await q.source.get(s.source_key);
return {
-- ...signalPayload(s), status: s.status, status_reason: s.status_reason, status_at: toIso(s.status_at), superseded_by: s.superseded_by,
-- trust: parse(s.trust, {}), editor_trust: trustMap('signal', s.id), created_at: toIso(s.created_at),
++ ...await signalPayload(s), status: s.status, status_reason: s.status_reason, status_at: toIso(s.status_at), superseded_by: s.superseded_by,
++ trust: parse(s.trust, {}), editor_trust: await trustMap('signal', s.id), created_at: toIso(s.created_at),
provenance: {
source_key: s.source_key, source_name: src ? src.name : null, source_homepage: src ? src.homepage_url : null,
source_item_id: s.source_item_id, item_revision: s.item_revision, item_kind: item ? item.kind : null,
@@@ -924,13 +922,13 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
}
/** `editor: false` (a reader): the person who wrote it is "an editor", as in the editorial log. */
-- function summaryRevisionView(summary, rev, canonicalId, { editor = true } = {}) {
++ async function summaryRevisionView(summary, rev, canonicalId, { editor = true } = {}) {
if (!rev) return null;
const rec = rev.meta && rev.meta.authorship;
-- const review = reviews.latest(summary.id, rev.number);
++ const review = await reviews.latest(summary.id, rev.number);
return {
-- number: rev.number, status: revisionStatus(summary, rev), overview: rev.content,
-- points: citationState(canonicalId, rev).map((p) => ({ key: p.key, kind: p.kind, text: p.kind === 'overview' ? null : p.text, supported: p.supported, citations: p.cites })),
++ number: rev.number, status: await revisionStatus(summary, rev), overview: rev.content,
++ points: (await citationState(canonicalId, rev)).map((p) => ({ key: p.key, kind: p.kind, text: p.kind === 'overview' ? null : p.text, supported: p.supported, citations: p.cites })),
authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, stub_provider: !!rec.stubProvider } : null,
disclosure: rec ? authorship.disclosure(rec, review) : null,
review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,
@@@ -947,12 +945,14 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
}
/** The summary's history as readers may see it, newest first (entity page). */
-- function publicHistory(summary) {
++ async function publicHistory(summary) {
if (!summary) return [];
-- return revisions.list(summary.id, { limit: 200 }).filter((r) => publicRevision(summary, r)).map((r) => {
-- const d = r.meta && r.meta.authorship ? authorship.disclosure(r.meta.authorship, reviews.latest(summary.id, r.number)) : null;
-- return { number: r.number, status: revisionStatus(summary, r), created_at: r.createdAt, disclosure: d ? d.short : null, correction: correctionView(r) };
-- });
++ const listed = await revisions.list(summary.id, { limit: 200 });
++ const shown = await Promise.all(listed.map(async (r) => await publicRevision(summary, r)));
++ return (await Promise.all(listed.filter((r, i) => shown[i]).map(async (r) => {
++ const d = r.meta && r.meta.authorship ? authorship.disclosure(r.meta.authorship, await reviews.latest(summary.id, r.number)) : null;
++ return { number: r.number, status: await revisionStatus(summary, r), created_at: r.createdAt, disclosure: d ? d.short : null, correction: correctionView(r) };
++ })));
}
// Audit detail readers never see: which correction request it was, what it said, how editors resolved it.
@@@ -970,19 +970,19 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
ENTITY_KINDS, ALIAS_TYPES, LINK_TYPES, TRUST_KEYS, SUMMARY_WORKFLOW,
// Lookups ----------------------------------------------------------------------------
-- findEntity(ref) { const s = String(ref || ''); return (ENTITY_ID_RE.test(s) ? q.entity.get(s) : q.entityBySlug.get(s)) || null; },
-- entity(ref) { return entityOrFail(ref); },
-- entityById(id) { return q.entity.get(id) || null; },
-- resolveRedirect(path) {
-- return redirects.resolve(path, { currentPath: (id) => { const e = q.entity.get(id); return e && e.state !== 'deleted' ? entityPath(e) : null; } });
++ async findEntity(ref) { const s = String(ref || ''); return (ENTITY_ID_RE.test(s) ? await q.entity.get(s) : await q.entityBySlug.get(s)) || null; },
++ async entity(ref) { return await entityOrFail(ref); },
++ async entityById(id) { return await q.entity.get(id) || null; },
++ async resolveRedirect(path) {
++ return await redirects.resolve(path, { currentPath: async (id) => { const e = await q.entity.get(id); return e && e.state !== 'deleted' ? entityPath(e) : null; } });
},
-- listEntities({ limit = 50, offset = 0 } = {}) { return { entities: q.listActive.all(Math.min(200, limit), Math.max(0, offset)), total: q.countActive.get().n }; },
-- search(text, { limit = 30 } = {}) {
++ async listEntities({ limit = 50, offset = 0 } = {}) { return { entities: await q.listActive.all(Math.min(200, limit), Math.max(0, offset)), total: (await q.countActive.get()).n }; },
++ async search(text, { limit = 30 } = {}) {
const s = String(text || '').trim().slice(0, 200);
if (!s) return [];
const esc = (x) => x.replace(/[\\%_]/g, (c) => `\\${c}`);
const n = norm.tryAlias('name', s) || s.toLowerCase();
-- return q.searchEntities.all({ like: `%${esc(s)}%`, nlike: `%${esc(n)}%`, limit: Math.min(100, limit) });
++ return await q.searchEntities.all({ like: `%${esc(s)}%`, nlike: `%${esc(n)}%`, limit: Math.min(100, limit) });
},
entityView,
signalView,
@@@ -991,215 +991,215 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
gateFacts,
/** Everything the entity page shows. `e` must be active (callers redirect merged entities). */
-- page(e, actor) {
-- const members = closure(e.id);
-- const signals = allSignalsIn(members);
++ async page(e, actor) {
++ const members = await closure(e.id);
++ const signals = await allSignalsIn(members);
const active = signals.filter((s) => s.status === 'active');
-- const agg = aggregateView(q.lastAggregate.get(e.id));
-- const summary = q.summary.get(e.id) || null;
-- const pub = summary && summary.state === 'published' && summary.published_revision ? revisions.get(summary.id, summary.published_revision) : null;
++ const agg = aggregateView(await q.lastAggregate.get(e.id));
++ const summary = await q.summary.get(e.id) || null;
++ const pub = summary && summary.state === 'published' && summary.published_revision ? await revisions.get(summary.id, summary.published_revision) : null;
const sourceKeys = [...new Set(signals.map((s) => s.source_key))].sort();
const editor = access.isEditor(actor);
return {
-- entity: entityView(e),
-- aliases: q.aliasesOf.all(e.id).map((a) => ({ id: a.id, type: a.type, value: a.value })),
-- merged_from: [...members].filter((id) => id !== e.id).map((id) => entityView(q.entity.get(id))),
-- links: [...q.linksFrom.all(e.id), ...q.linksTo.all(e.id)].map((l) => ({
++ entity: await entityView(e),
++ aliases: (await q.aliasesOf.all(e.id)).map((a) => ({ id: a.id, type: a.type, value: a.value })),
++ merged_from: (await Promise.all([...members].filter((id) => id !== e.id).map(async (id) => await entityView(await q.entity.get(id))))),
++ links: (await Promise.all([...await q.linksFrom.all(e.id), ...await q.linksTo.all(e.id)].map(async (l) => ({
id: l.id, type: l.type, from: l.from_entity, to: l.to_entity, ref: parse(l.ref, null), note: l.note,
-- other: l.to_entity ? entityView(q.entity.get(l.from_entity === e.id ? l.to_entity : l.from_entity)) : null,
++ other: l.to_entity ? await entityView(await q.entity.get(l.from_entity === e.id ? l.to_entity : l.from_entity)) : null,
direction: l.from_entity === e.id ? 'out' : 'in', created_at: toIso(l.created_at),
-- })),
++ })))),
aggregate: agg && agg.result ? agg : null,
aggregate_revision: agg ? agg.revision : null,
-- signals: active.map(signalView),
-- inactive_signals: signals.filter((s) => s.status !== 'active').map(signalView),
-- sources: sourceKeys.map((k) => { const r = q.source.get(k); return { key: k, name: r && r.name, homepage_url: r && r.homepage_url, license_note: r && r.license_note, terms_note: r && r.terms_note, status: r && r.status, stale: r ? r.stale === 1 : null, last_success_at: r && r.last_success_at, trust: trustMap('source', k) }; }),
-- entity_trust: trustMap('entity', e.id),
++ signals: (await Promise.all(active.map(signalView))),
++ inactive_signals: (await Promise.all(signals.filter((s) => s.status !== 'active').map(signalView))),
++ sources: (await Promise.all(sourceKeys.map(async (k) => { const r = await q.source.get(k); return { key: k, name: r && r.name, homepage_url: r && r.homepage_url, license_note: r && r.license_note, terms_note: r && r.terms_note, status: r && r.status, stale: r ? r.stale === 1 : null, last_success_at: r && r.last_success_at, trust: await trustMap('source', k) }; }))),
++ entity_trust: await trustMap('entity', e.id),
summary: summary ? {
-- id: summary.id, state: summary.state, head_revision: revisions.headNumber(summary.id), flagged: !!summary.flagged, flag_reason: summary.flag_reason, flagged_at: toIso(summary.flagged_at),
++ id: summary.id, state: summary.state, head_revision: await revisions.headNumber(summary.id), flagged: !!summary.flagged, flag_reason: summary.flag_reason, flagged_at: toIso(summary.flagged_at),
published_at: toIso(summary.published_at), revision_published_at: toIso(summary.revision_published_at),
-- published: pub ? summaryRevisionView(summary, pub, e.id, { editor }) : null,
-- pending: editor ? pendingRevisions(summary).map((p) => summaryRevisionView(summary, p.rev, e.id)) : pendingRevisions(summary).length,
-- history: publicHistory(summary),
++ published: pub ? await summaryRevisionView(summary, pub, e.id, { editor }) : null,
++ pending: editor ? (await Promise.all((await pendingRevisions(summary)).map(async (p) => await summaryRevisionView(summary, p.rev, e.id)))) : (await pendingRevisions(summary)).length,
++ history: await publicHistory(summary),
} : null,
-- open_corrections: q.openCorrectionsOf.get(e.id).n,
-- decision: decide(e),
++ open_corrections: (await q.openCorrectionsOf.get(e.id)).n,
++ decision: await decide(e),
editor,
};
},
-- history(e, actor) {
++ async history(e, actor) {
const editor = access.isEditor(actor);
if (e.state === 'deleted' && !editor) fail(410, 'entity.deleted', 'This entity was deleted');
-- const summary = q.summary.get(e.id);
-- const revs = summary ? revisions.list(summary.id, { limit: 200 }) : [];
++ const summary = await q.summary.get(e.id);
++ const revs = summary ? await revisions.list(summary.id, { limit: 200 }) : [];
return {
-- entity: entityView(e),
-- aggregates: q.aggregates.all(e.id, 200).map(aggregateView),
-- summary_revisions: summary ? revs.filter((r) => editor || publicRevision(summary, r))
-- .map((r) => summaryRevisionView(summary, r, e.id, { editor })) : [],
-- merges: q.mergeLinksOf.all(e.id, e.id).map((l) => ({
-- id: l.id, from: entityView(q.entity.get(l.from_entity)), to: entityView(q.entity.get(l.to_entity)), note: l.note,
++ entity: await entityView(e),
++ aggregates: (await q.aggregates.all(e.id, 200)).map(aggregateView),
++ summary_revisions: summary ? (await Promise.all((await Promise.all(revs.map(async (r) => ((editor || await publicRevision(summary, r)) ? r : null)))).filter(Boolean)
++ .map(async (r) => await summaryRevisionView(summary, r, e.id, { editor })))) : [],
++ merges: (await Promise.all((await q.mergeLinksOf.all(e.id, e.id)).map(async (l) => ({
++ id: l.id, from: await entityView(await q.entity.get(l.from_entity)), to: await entityView(await q.entity.get(l.to_entity)), note: l.note,
merged_at: toIso(l.created_at), merged_by: editor ? l.created_by : null, split_at: toIso(l.ended_at), split_by: editor ? l.ended_by : null, split_note: l.end_note,
-- })),
-- signals: allSignalsIn(closure(e.id)).map(signalView),
-- audit: q.auditOf.all(e.id, e.id).map((r) => ({
++ })))),
++ signals: (await Promise.all((await allSignalsIn(await closure(e.id))).map(signalView))),
++ audit: (await q.auditOf.all(e.id, e.id)).map((r) => ({
id: r.id, at: toIso(r.at), action: r.action, target: editor || !/^correction\./.test(r.action) ? r.target : null,
actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: auditDetail(r, editor),
})),
};
},
-- summaryRevision(e, n, actor) {
++ async summaryRevision(e, n, actor) {
if (e.state === 'deleted' && !access.isEditor(actor)) fail(410, 'entity.deleted', 'This entity was deleted');
-- const summary = q.summary.get(e.id);
++ const summary = await q.summary.get(e.id);
if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
-- const rev = revisions.get(summary.id, Number(n));
++ const rev = await revisions.get(summary.id, Number(n));
if (!rev) fail(404, 'revision.not_found', `No revision ${n}`);
const editor = access.isEditor(actor);
-- if (!editor && !publicRevision(summary, rev)) fail(404, 'revision.not_found', `No revision ${n}`);
-- return summaryRevisionView(summary, rev, e.id, { editor });
++ if (!editor && !await publicRevision(summary, rev)) fail(404, 'revision.not_found', `No revision ${n}`);
++ return await summaryRevisionView(summary, rev, e.id, { editor });
},
// Resolve (reviews.entity.resolve) ---------------------------------------------------
/** { name?, url?, sku?, gtin?, mpn?, source?, external? } → { match, entity, via, rule, candidates } */
-- resolve(input = {}) {
++ async resolve(input = {}) {
const strong = [];
for (const t of ['source', 'external', 'url', 'gtin', 'sku', 'mpn']) if (input[t]) strong.push({ type: t, value: String(input[t]) });
const names = input.name ? [String(input.name)] : [];
if (!strong.length && !names.length) fail(422, 'resolve.empty', 'Give at least one of name, url, gtin, sku, mpn, source, external');
-- const r = resolveIdentifiers({ strong, names });
-- const canon = r.entity_id ? canonicalOf(r.entity_id) : null;
++ const r = await resolveIdentifiers({ strong, names });
++ const canon = r.entity_id ? await canonicalOf(r.entity_id) : null;
return {
match: r.resolution === 'resolved' ? 'exact' : r.resolution === 'ambiguous' ? 'ambiguous' : 'none',
-- entity: canon ? entityView(q.entity.get(canon)) : null,
-- via: r.entity_id && r.entity_id !== canon ? entityView(q.entity.get(r.entity_id)) : null,
++ entity: canon ? await entityView(await q.entity.get(canon)) : null,
++ via: r.entity_id && r.entity_id !== canon ? await entityView(await q.entity.get(r.entity_id)) : null,
rule: r.rule,
-- candidates: r.candidates.map((c) => ({ rule: c.rule, entity: entityView(q.entity.get(c.entity_id)) })),
++ candidates: (await Promise.all(r.candidates.map(async (c) => ({ rule: c.rule, entity: await entityView(await q.entity.get(c.entity_id)) })))),
};
},
// Entities (editors) -----------------------------------------------------------------
-- createEntity({ name, kind = 'other', description = null, slug = null, aliases = [] } = {}, actor) {
++ async createEntity({ name, kind = 'other', description = null, slug = null, aliases = [] } = {}, actor) {
requireEditor(actor, 'Creating an entity');
const clean = checkName(name);
if (!ENTITY_KINDS.includes(kind)) fail(422, 'entity.invalid_kind', `kind is one of ${ENTITY_KINDS.join(', ')}`);
if (!Array.isArray(aliases)) fail(422, 'alias.invalid', 'aliases is a list of { type, value }');
-- return tx(() => {
++ return await tx(async () => {
const t = now();
const id = `ent_${ulid(t)}`;
const s = slug ? norm.slug(slug) : null;
if (slug && !s) fail(422, 'entity.invalid_slug', 'That slug has no letters or digits');
-- if (s && (q.entityBySlug.get(s))) fail(409, 'entity.slug_taken', `/e/${s} is taken`);
-- const finalSlug = s || uniqueSlug(clean);
-- redirects.release(`/e/${finalSlug}`);
-- q.insertEntity.run({ id, slug: finalSlug, name: clean, kind, description: description ? String(description).trim().slice(0, 2000) || null : null, created_by: actorId(actor), now: t });
-- insertAlias(id, 'name', clean, actor);
-- for (const a of aliases) insertAlias(id, a && a.type, a && a.value, actor);
-- audit(actor, 'entity.created', id, null, { name: clean, kind, aliases });
-- const settled = reresolvePending(actor);
-- syncEntity(id);
-- return { entity: q.entity.get(id), settled_items: settled };
++ if (s && (await q.entityBySlug.get(s))) fail(409, 'entity.slug_taken', `/e/${s} is taken`);
++ const finalSlug = s || await uniqueSlug(clean);
++ await redirects.release(`/e/${finalSlug}`);
++ await q.insertEntity.run({ id, slug: finalSlug, name: clean, kind, description: description ? String(description).trim().slice(0, 2000) || null : null, created_by: actorId(actor), now: t });
++ await insertAlias(id, 'name', clean, actor);
++ for (const a of aliases) await insertAlias(id, a && a.type, a && a.value, actor);
++ await audit(actor, 'entity.created', id, null, { name: clean, kind, aliases });
++ const settled = await reresolvePending(actor);
++ await syncEntity(id);
++ return { entity: await q.entity.get(id), settled_items: settled };
});
},
-- updateEntity(ref, { name, kind, description, slug, noindex } = {}, actor) {
++ async updateEntity(ref, { name, kind, description, slug, noindex } = {}, actor) {
requireEditorPerson(actor, 'Editing an entity');
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
const next = { ...e };
const changes = {};
-- if (name != null && name !== e.name) { next.name = checkName(name); changes.name = [e.name, next.name]; insertAlias(e.id, 'name', next.name, actor); }
++ if (name != null && name !== e.name) { next.name = checkName(name); changes.name = [e.name, next.name]; await insertAlias(e.id, 'name', next.name, actor); }
if (kind != null && kind !== e.kind) { if (!ENTITY_KINDS.includes(kind)) fail(422, 'entity.invalid_kind', `kind is one of ${ENTITY_KINDS.join(', ')}`); next.kind = kind; changes.kind = [e.kind, kind]; }
if (description !== undefined && (description || null) !== e.description) { next.description = description ? String(description).trim().slice(0, 2000) || null : null; changes.description = true; }
if (noindex != null && !!noindex !== !!e.noindex) { next.noindex = noindex ? 1 : 0; changes.noindex = !!noindex; }
if (slug != null && slug !== e.slug) {
const s = norm.slug(slug);
if (!s) fail(422, 'entity.invalid_slug', 'That slug has no letters or digits');
-- const other = q.entityBySlug.get(s);
++ const other = await q.entityBySlug.get(s);
if (other && other.id !== e.id) fail(409, 'entity.slug_taken', `/e/${s} is taken`);
-- redirects.recordMove(e.id, `/e/${e.slug}`, `/e/${s}`, { reason: 'renamed' });
++ await redirects.recordMove(e.id, `/e/${e.slug}`, `/e/${s}`, { reason: 'renamed' });
next.slug = s; changes.slug = [e.slug, s];
}
-- db.prepare('UPDATE review_entities SET name = ?, kind = ?, description = ?, slug = ?, noindex = ?, updated_at = ? WHERE id = ?')
++ await db.prepare('UPDATE review_entities SET name = ?, kind = ?, description = ?, slug = ?, noindex = ?, updated_at = ? WHERE id = ?')
.run(next.name, next.kind, next.description, next.slug, next.noindex, now(), e.id);
-- if (Object.keys(changes).length) audit(actor, 'entity.updated', e.id, null, changes);
-- syncEntity(e.id);
-- return q.entity.get(e.id);
++ if (Object.keys(changes).length) await audit(actor, 'entity.updated', e.id, null, changes);
++ await syncEntity(e.id);
++ return await q.entity.get(e.id);
});
},
-- deleteEntity(ref, { note = null } = {}, actor) {
++ async deleteEntity(ref, { note = null } = {}, actor) {
requireEditorPerson(actor, 'Deleting an entity');
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
-- if (q.mergedInto.all(e.id).length) fail(409, 'entity.has_merges', 'Split the entities merged into this one first');
-- const active = q.activeSignalsOfEntity.all(e.id);
++ if ((await q.mergedInto.all(e.id)).length) fail(409, 'entity.has_merges', 'Split the entities merged into this one first');
++ const active = await q.activeSignalsOfEntity.all(e.id);
if (active.length) fail(409, 'entity.has_signals', 'This entity has live signals: reattribute or ignore their items first');
-- db.prepare("UPDATE review_entities SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), e.id);
-- audit(actor, 'entity.deleted', e.id, null, { note });
-- syncEntity(e.id);
-- return q.entity.get(e.id);
++ await db.prepare("UPDATE review_entities SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), e.id);
++ await audit(actor, 'entity.deleted', e.id, null, { note });
++ await syncEntity(e.id);
++ return await q.entity.get(e.id);
});
},
-- addAlias(ref, { type, value } = {}, actor) {
++ async addAlias(ref, { type, value } = {}, actor) {
requireEditorPerson(actor, 'Adding an alias');
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
if (e.state === 'deleted') fail(409, 'entity.not_active', 'This entity was deleted');
-- const a = insertAlias(e.id, type, value, actor);
-- audit(actor, 'alias.added', e.id, a.id, { type: a.type, value: a.value });
-- const settled = reresolvePending(actor);
++ const a = await insertAlias(e.id, type, value, actor);
++ await audit(actor, 'alias.added', e.id, a.id, { type: a.type, value: a.value });
++ const settled = await reresolvePending(actor);
return { alias: a, settled_items: settled };
});
},
-- removeAlias(ref, aliasId, actor) {
++ async removeAlias(ref, aliasId, actor) {
requireEditorPerson(actor, 'Removing an alias');
-- return tx(() => {
-- const e = entityOrFail(ref);
-- const a = q.alias.get(String(aliasId));
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
++ const a = await q.alias.get(String(aliasId));
if (!a || a.entity_id !== e.id || a.removed_at) fail(404, 'alias.not_found', 'No such alias');
-- q.removeAlias.run(now(), actorId(actor), a.id);
-- audit(actor, 'alias.removed', e.id, a.id, { type: a.type, value: a.value });
-- return q.alias.get(a.id);
++ await q.removeAlias.run(now(), actorId(actor), a.id);
++ await audit(actor, 'alias.removed', e.id, a.id, { type: a.type, value: a.value });
++ return await q.alias.get(a.id);
});
},
-- addLink(ref, { type, to = null, ref: entityRef = null, note = null } = {}, actor) {
++ async addLink(ref, { type, to = null, ref: entityRef = null, note = null } = {}, actor) {
requireEditorPerson(actor, 'Linking entities');
if (!LINK_TYPES.includes(type) || type === 'merged_into') fail(422, 'link.invalid', `type is one of ${LINK_TYPES.filter((t) => t !== 'merged_into').join(', ')} (merges go through merge)`);
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
let target = null;
let refJson = null;
if (type === 'external_ref') {
const { entityRef: mk } = require('openvibe-publishing/discussion');
try { refJson = JSON.stringify(mk(entityRef || {})); } catch (err) { fail(422, 'link.invalid', err.message); }
} else {
-- target = entityOrFail(to);
++ target = await entityOrFail(to);
if (target.id === e.id) fail(422, 'link.invalid', 'An entity cannot link to itself');
}
const t = now();
const id = `lnk_${ulid(t)}`;
-- q.insertLink.run({ id, from: e.id, to: target ? target.id : null, type, ref: refJson, note: note ? String(note).slice(0, 500) : null, by: actorId(actor), now: t });
-- audit(actor, 'link.added', e.id, id, { type, to: target ? target.id : null, ref: parse(refJson, null) });
-- return q.link.get(id);
++ await q.insertLink.run({ id, from: e.id, to: target ? target.id : null, type, ref: refJson, note: note ? String(note).slice(0, 500) : null, by: actorId(actor), now: t });
++ await audit(actor, 'link.added', e.id, id, { type, to: target ? target.id : null, ref: parse(refJson, null) });
++ return await q.link.get(id);
});
},
-- endLink(ref, linkId, { note = null } = {}, actor) {
++ async endLink(ref, linkId, { note = null } = {}, actor) {
requireEditorPerson(actor, 'Removing a link');
-- return tx(() => {
-- const e = entityOrFail(ref);
-- const l = q.link.get(String(linkId));
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
++ const l = await q.link.get(String(linkId));
if (!l || (l.from_entity !== e.id && l.to_entity !== e.id) || l.ended_at || l.type === 'merged_into') fail(404, 'link.not_found', 'No such link (merges are undone with split)');
-- q.endLink.run(now(), actorId(actor), note ? String(note).slice(0, 500) : null, l.id);
-- audit(actor, 'link.ended', e.id, l.id, { type: l.type });
-- return q.link.get(l.id);
++ await q.endLink.run(now(), actorId(actor), note ? String(note).slice(0, 500) : null, l.id);
++ await audit(actor, 'link.ended', e.id, l.id, { type: l.type });
++ return await q.link.get(l.id);
});
},
@@@ -1210,65 +1210,65 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* link, and the target's aggregate is recomputed over both. That is why a split restores
* the pre-merge attribution exactly.
*/
-- merge(ref, { into, note = null } = {}, actor) {
++ async merge(ref, { into, note = null } = {}, actor) {
const who = requireEditorPerson(actor, 'Merging entities');
-- return tx(() => {
-- const src = entityOrFail(ref);
-- const tgt = entityOrFail(into);
++ return await tx(async () => {
++ const src = await entityOrFail(ref);
++ const tgt = await entityOrFail(into);
if (src.id === tgt.id) fail(422, 'merge.same_entity', 'An entity cannot be merged into itself');
if (src.state !== 'active') fail(409, 'merge.not_active', `"${src.name}" is ${src.state}`);
-- if (tgt.state !== 'active') fail(409, 'merge.target_not_active', `"${tgt.name}" is ${tgt.state}${tgt.state === 'merged' ? `; merge into ${canonicalOf(tgt.id)} instead` : ''}`, { canonical_id: canonicalOf(tgt.id) });
-- if (closure(src.id).has(tgt.id)) fail(409, 'merge.cycle', 'The target is merged into this entity');
++ if (tgt.state !== 'active') fail(409, 'merge.target_not_active', `"${tgt.name}" is ${tgt.state}${tgt.state === 'merged' ? `; merge into ${await canonicalOf(tgt.id)} instead` : ''}`, { canonical_id: await canonicalOf(tgt.id) });
++ if ((await closure(src.id)).has(tgt.id)) fail(409, 'merge.cycle', 'The target is merged into this entity');
const t = now();
const linkId = `lnk_${ulid(t)}`;
-- const moved = activeSignalsIn(closure(src.id)).map((s) => s.id);
-- q.insertLink.run({ id: linkId, from: src.id, to: tgt.id, type: 'merged_into', ref: null, note: note ? String(note).slice(0, 500) : null, by: who, now: t });
-- db.prepare("UPDATE review_entities SET state = 'merged', merged_into = ?, updated_at = ? WHERE id = ?").run(tgt.id, t, src.id);
-- audit(actor, 'entity.merged', src.id, tgt.id, {
++ const moved = (await activeSignalsIn(await closure(src.id))).map((s) => s.id);
++ await q.insertLink.run({ id: linkId, from: src.id, to: tgt.id, type: 'merged_into', ref: null, note: note ? String(note).slice(0, 500) : null, by: who, now: t });
++ await db.prepare("UPDATE review_entities SET state = 'merged', merged_into = ?, updated_at = ? WHERE id = ?").run(tgt.id, t, src.id);
++ await audit(actor, 'entity.merged', src.id, tgt.id, {
link_id: linkId, into: tgt.id, note, signals: moved,
-- aliases: q.aliasesOf.all(src.id).map((a) => a.id),
-- merged_members: [...closure(src.id)].filter((id) => id !== src.id),
++ aliases: (await q.aliasesOf.all(src.id)).map((a) => a.id),
++ merged_members: [...await closure(src.id)].filter((id) => id !== src.id),
});
-- refreshAggregate(tgt.id, 'merge');
-- touchEntity(tgt.id);
-- syncEntity(src.id);
-- syncEntity(tgt.id);
-- entityEvent('reviews.entity.merged', src, actor, {
++ await refreshAggregate(tgt.id, 'merge');
++ await touchEntity(tgt.id);
++ await syncEntity(src.id);
++ await syncEntity(tgt.id);
++ await entityEvent('reviews.entity.merged', src, actor, {
entity_id: src.id, into_entity_id: tgt.id, link_id: linkId, signal_count: moved.length,
url: entityUrl(src), into_url: entityUrl(tgt), note: note ? String(note).slice(0, 500) : null,
});
-- return { link: q.link.get(linkId), entity: q.entity.get(src.id), into: q.entity.get(tgt.id), signals: moved };
++ return { link: await q.link.get(linkId), entity: await q.entity.get(src.id), into: await q.entity.get(tgt.id), signals: moved };
});
},
-- split(ref, { note = null } = {}, actor) {
++ async split(ref, { note = null } = {}, actor) {
const who = requireEditorPerson(actor, 'Splitting entities');
-- return tx(() => {
-- const src = entityOrFail(ref);
++ return await tx(async () => {
++ const src = await entityOrFail(ref);
if (src.state !== 'merged') fail(409, 'split.not_merged', `"${src.name}" is not merged into another entity`);
-- const link = q.activeMerge.get(src.id);
++ const link = await q.activeMerge.get(src.id);
if (!link) fail(409, 'split.no_merge_link', 'No active merge link for this entity');
const t = now();
-- q.endLink.run(t, who, note ? String(note).slice(0, 500) : null, link.id);
-- db.prepare("UPDATE review_entities SET state = 'active', merged_into = NULL, updated_at = ? WHERE id = ?").run(t, src.id);
-- const restored = activeSignalsIn(closure(src.id)).map((s) => s.id);
-- audit(actor, 'entity.split', src.id, link.to_entity, { link_id: link.id, from: link.to_entity, note, signals: restored });
-- refreshAggregate(src.id, 'split');
-- refreshAggregate(link.to_entity, 'split');
++ await q.endLink.run(t, who, note ? String(note).slice(0, 500) : null, link.id);
++ await db.prepare("UPDATE review_entities SET state = 'active', merged_into = NULL, updated_at = ? WHERE id = ?").run(t, src.id);
++ const restored = (await activeSignalsIn(await closure(src.id))).map((s) => s.id);
++ await audit(actor, 'entity.split', src.id, link.to_entity, { link_id: link.id, from: link.to_entity, note, signals: restored });
++ await refreshAggregate(src.id, 'split');
++ await refreshAggregate(link.to_entity, 'split');
// The former target's summary may cite signals that went back with the split.
-- const tgtCanon = canonicalOf(link.to_entity);
-- const tgtSummary = tgtCanon && q.summary.get(tgtCanon);
-- if (tgtSummary) flagSummary(tgtSummary.id, 'Entities were split: a cited signal now belongs to another entity', restored);
-- touchEntity(src.id);
-- if (tgtCanon) touchEntity(tgtCanon);
-- syncEntity(src.id);
-- if (tgtCanon) syncEntity(tgtCanon);
-- const from = q.entity.get(link.to_entity);
-- entityEvent('reviews.entity.split', src, actor, {
++ const tgtCanon = await canonicalOf(link.to_entity);
++ const tgtSummary = tgtCanon && await q.summary.get(tgtCanon);
++ if (tgtSummary) await flagSummary(tgtSummary.id, 'Entities were split: a cited signal now belongs to another entity', restored);
++ await touchEntity(src.id);
++ if (tgtCanon) await touchEntity(tgtCanon);
++ await syncEntity(src.id);
++ if (tgtCanon) await syncEntity(tgtCanon);
++ const from = await q.entity.get(link.to_entity);
++ await entityEvent('reviews.entity.split', src, actor, {
entity_id: src.id, from_entity_id: link.to_entity, link_id: link.id, signal_count: restored.length,
-- url: entityUrl(q.entity.get(src.id)), from_url: from ? entityUrl(from) : null, note: note ? String(note).slice(0, 500) : null,
++ url: entityUrl(await q.entity.get(src.id)), from_url: from ? entityUrl(from) : null, note: note ? String(note).slice(0, 500) : null,
});
-- return { link: q.link.get(link.id), entity: q.entity.get(src.id), from: from, signals: restored };
++ return { link: await q.link.get(link.id), entity: await q.entity.get(src.id), from: from, signals: restored };
});
},
@@@ -1280,26 +1280,26 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
async importItem(itemId, sources, actor) {
const { item, source } = await sources.getItem(String(itemId || ''));
let info = source || null;
-- if (!q.source.get(item.source_key)) {
++ if (!await q.source.get(item.source_key)) {
try { const full = await sources.getSource(item.source_key); if (full) info = { ...full, ...(source || {}) }; } catch { /* the notes come with the item */ }
}
-- return applyItem(item, { sourceInfo: info, actor });
++ return await applyItem(item, { sourceInfo: info, actor });
},
/** A source item removal we heard about (sources.item.removed event). */
-- removeItem(itemId, reason, actor) {
-- const row = q.item.get(String(itemId));
++ async removeItem(itemId, reason, actor) {
++ const row = await q.item.get(String(itemId));
if (!row) return { outcome: 'ignored:unknown_item' };
if (row.state === 'removed') return { outcome: 'unchanged' };
-- return tx(() => {
-- q.removeItem.run(new Date(now()).toISOString(), String(reason || 'removed by its source').slice(0, 500), now(), row.id);
-- reconcileItemSignal(q.item.get(row.id), actor);
-- return { outcome: 'removed', item: q.item.get(row.id) };
++ return await tx(async () => {
++ await q.removeItem.run(new Date(now()).toISOString(), String(reason || 'removed by its source').slice(0, 500), now(), row.id);
++ await reconcileItemSignal(await q.item.get(row.id), actor);
++ return { outcome: 'removed', item: await q.item.get(row.id) };
});
},
-- item(itemId) { return q.item.get(String(itemId)) || null; },
-- itemView(row) {
++ async item(itemId) { return await q.item.get(String(itemId)) || null; },
++ async itemView(row) {
if (!row) return null;
const fields = parse(row.fields, {});
return {
@@@ -1307,50 +1307,50 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
item_revision: row.item_revision, published_at: row.published_at, retrieved_at: row.retrieved_at, license_note: row.license_note, terms_note: row.terms_note,
fields, state: row.state, removed_at: row.removed_at, removed_reason: row.removed_reason,
resolution: row.resolution, entity_id: row.entity_id, resolution_rule: row.resolution_rule,
-- candidates: parse(row.candidates, []).map((c) => ({ ...c, entity: q.entity.get(c.entity_id) ? entityView(q.entity.get(c.entity_id)) : null })),
-- signal_note: row.signal_note, signal: (() => { const s = q.activeSignalOfItem.get(row.id); return s ? signalPayload(s) : null; })(),
++ candidates: (await Promise.all(parse(row.candidates, []).map(async (c) => ({ ...c, entity: await q.entity.get(c.entity_id) ? await entityView(await q.entity.get(c.entity_id)) : null })))),
++ signal_note: row.signal_note, signal: (async () => { const s = await q.activeSignalOfItem.get(row.id); return s ? await signalPayload(s) : null; })(),
};
},
-- resolutionQueue(limit = 200) { return q.queueItems.all(Math.min(500, limit)); },
++ async resolutionQueue(limit = 200) { return await q.queueItems.all(Math.min(500, limit)); },
/** An editor settles an item: attach it to an entity (optionally remember the identifier). */
-- confirmResolution(itemId, { entity: entityRef, add_alias: addAlias = null } = {}, actor) {
++ async confirmResolution(itemId, { entity: entityRef, add_alias: addAlias = null } = {}, actor) {
const who = requireEditorPerson(actor, 'Confirming a resolution');
-- return tx(() => {
-- const row = q.item.get(String(itemId));
++ return await tx(async () => {
++ const row = await q.item.get(String(itemId));
if (!row) fail(404, 'item.not_found', 'Reviews has not read that item');
if (row.state !== 'active') fail(409, 'item.removed', 'Its source removed this item');
-- const e = entityOrFail(entityRef);
++ const e = await entityOrFail(entityRef);
if (e.state !== 'active') fail(409, 'entity.not_active', `"${e.name}" is ${e.state}`);
const before = { resolution: row.resolution, entity_id: row.entity_id };
if (addAlias) {
const ids = itemIdentifiers(row);
const pick = addAlias === 'name' ? (ids.names[0] ? { type: 'name', value: ids.names[0] } : null) : ids.strong.find((x) => x.type === addAlias);
if (!pick) fail(422, 'alias.invalid', `The item has no ${addAlias} to remember`);
-- insertAlias(e.id, pick.type, pick.value, actor);
++ await insertAlias(e.id, pick.type, pick.value, actor);
}
-- const fresh = setItemResolution(row, { resolution: 'resolved', entity_id: e.id, rule: 'editor', candidates: [] }, who);
-- const out = reconcileItemSignal(fresh, actor);
-- audit(actor, 'item.resolved', e.id, row.id, { before, rule: 'editor', add_alias: addAlias || null, signal: out.signal ? out.signal.id : null });
-- const settled = addAlias ? reresolvePending(actor) : 0;
-- return { item: q.item.get(row.id), signal: out.signal || null, settled_items: settled };
++ const fresh = await setItemResolution(row, { resolution: 'resolved', entity_id: e.id, rule: 'editor', candidates: [] }, who);
++ const out = await reconcileItemSignal(fresh, actor);
++ await audit(actor, 'item.resolved', e.id, row.id, { before, rule: 'editor', add_alias: addAlias || null, signal: out.signal ? out.signal.id : null });
++ const settled = addAlias ? await reresolvePending(actor) : 0;
++ return { item: await q.item.get(row.id), signal: out.signal || null, settled_items: settled };
});
},
-- ignoreItem(itemId, { note = null } = {}, actor) {
++ async ignoreItem(itemId, { note = null } = {}, actor) {
requireEditorPerson(actor, 'Ignoring an item');
-- return tx(() => {
-- const row = q.item.get(String(itemId));
++ return await tx(async () => {
++ const row = await q.item.get(String(itemId));
if (!row) fail(404, 'item.not_found', 'Reviews has not read that item');
-- const fresh = setItemResolution(row, { resolution: 'ignored', entity_id: null, rule: 'editor', candidates: [] }, actorId(actor));
-- reconcileItemSignal(fresh, actor);
-- audit(actor, 'item.ignored', row.entity_id, row.id, { note });
-- return q.item.get(row.id);
++ const fresh = await setItemResolution(row, { resolution: 'ignored', entity_id: null, rule: 'editor', candidates: [] }, actorId(actor));
++ await reconcileItemSignal(fresh, actor);
++ await audit(actor, 'item.ignored', row.entity_id, row.id, { note });
++ return await q.item.get(row.id);
});
},
// Trust metadata --------------------------------------------------------------------
-- setTrust({ scope, scope_id: scopeId, key, value, note = null } = {}, actor) {
++ async setTrust({ scope, scope_id: scopeId, key, value, note = null } = {}, actor) {
requireEditorPerson(actor, 'Trust decisions');
if (!['source', 'signal', 'entity'].includes(scope)) fail(422, 'trust.invalid', 'scope is source, signal or entity');
if (!TRUST_KEYS.includes(key)) fail(422, 'trust.invalid', `key is one of ${TRUST_KEYS.join(', ')}`);
@@@ -1358,22 +1358,22 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (key === 'aggregate' && !['include', 'exclude'].includes(v)) fail(422, 'trust.invalid', 'aggregate is include or exclude');
if (key === 'aggregate' && v === 'exclude' && !String(note || '').trim()) fail(422, 'trust.reason_required', 'Excluding from the aggregate needs a reason readers can see');
if (key === 'aggregate' && scope === 'entity') fail(422, 'trust.invalid', 'Exclude a source or a signal, not a whole entity');
-- return tx(() => {
++ return await tx(async () => {
let entityIds = [];
-- if (scope === 'source') { if (!q.source.get(String(scopeId))) fail(404, 'source.not_found', 'Reviews has not seen that source'); entityIds = q.entitiesWithSourceSignals.all(String(scopeId)).map((r) => r.entity_id); }
-- if (scope === 'signal') { const s = q.signal.get(String(scopeId)); if (!s) fail(404, 'signal.not_found', 'No such signal'); entityIds = [s.entity_id]; }
-- if (scope === 'entity') { const e = entityOrFail(scopeId); scopeId = e.id; entityIds = [e.id]; }
-- const cur = q.trustOne.get(scope, String(scopeId), key);
++ if (scope === 'source') { if (!await q.source.get(String(scopeId))) fail(404, 'source.not_found', 'Reviews has not seen that source'); entityIds = (await q.entitiesWithSourceSignals.all(String(scopeId))).map((r) => r.entity_id); }
++ if (scope === 'signal') { const s = await q.signal.get(String(scopeId)); if (!s) fail(404, 'signal.not_found', 'No such signal'); entityIds = [s.entity_id]; }
++ if (scope === 'entity') { const e = await entityOrFail(scopeId); scopeId = e.id; entityIds = [e.id]; }
++ const cur = await q.trustOne.get(scope, String(scopeId), key);
const who = actorId(actor);
-- if (cur) q.trustEnd.run(now(), who, cur.id);
-- if (v && !(key === 'aggregate' && v === 'include')) q.trustInsert.run(scope, String(scopeId), key, v, note ? String(note).slice(0, 1000) : null, who, now());
-- audit(actor, 'trust.set', scope === 'entity' ? scopeId : (entityIds.length === 1 ? canonicalOf(entityIds[0]) : null), `${scope}:${scopeId}`, { key, value: v, note, previous: cur ? { value: cur.value, note: cur.note } : null });
-- const canon = [...new Set(entityIds.map(canonicalOf).filter(Boolean))];
-- for (const id of canon) { refreshAggregate(id, 'trust'); touchEntity(id); syncEntity(id); }
-- return { scope, scope_id: scopeId, key, current: q.trustOne.get(scope, String(scopeId), key) || null, affected_entities: canon };
++ if (cur) await q.trustEnd.run(now(), who, cur.id);
++ if (v && !(key === 'aggregate' && v === 'include')) await q.trustInsert.run(scope, String(scopeId), key, v, note ? String(note).slice(0, 1000) : null, who, now());
++ await audit(actor, 'trust.set', scope === 'entity' ? scopeId : (entityIds.length === 1 ? await canonicalOf(entityIds[0]) : null), `${scope}:${scopeId}`, { key, value: v, note, previous: cur ? { value: cur.value, note: cur.note } : null });
++ const canon = [...new Set((await Promise.all(entityIds.map(canonicalOf))).filter(Boolean))];
++ for (const id of canon) { await refreshAggregate(id, 'trust'); await touchEntity(id); await syncEntity(id); }
++ return { scope, scope_id: scopeId, key, current: await q.trustOne.get(scope, String(scopeId), key) || null, affected_entities: canon };
});
},
-- trustHistory(scope, id) { return q.trustHistory.all(scope, id); },
++ async trustHistory(scope, id) { return await q.trustHistory.all(scope, id); },
trustMap,
// Summaries (reviews.summary.publish | reviews.summary.propose) ----------------------
@@@ -1382,24 +1382,24 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* correction_note (and optionally the correction_id of a reader's request it answers) the
* revision corrects the published summary: it carries the note and is published at once.
*/
-- writeSummary(ref, input = {}, actor) {
++ async writeSummary(ref, input = {}, actor) {
const who = requireEditorPerson(actor, 'Writing a summary');
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
-- if (wantsCorrection(input)) return correctSummary(e, input, { note: input.correction_note, requestId: input.correction_id }, actor, who);
-- const body = checkSummaryInput(e.id, input);
-- const summary = ensureSummary(e.id);
-- const revision = createRevision({
-- entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
++ if (wantsCorrection(input)) return await correctSummary(e, input, { note: input.correction_note, requestId: input.correction_id }, actor, who);
++ const body = await checkSummaryInput(e.id, input);
++ const summary = await ensureSummary(e.id);
++ const revision = await createRevision({
++ entityId: summary.id, expectedRevision: expectedOf(input, await revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,
message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,
});
-- writeCitations(summary, revision);
-- audit(actor, 'summary.revision', e.id, summary.id, { revision: revision.number });
++ await writeCitations(summary, revision);
++ await audit(actor, 'summary.revision', e.id, summary.id, { revision: revision.number });
let published = null;
-- if (input.publish === true || input.publish === 'true' || input.publish === '1') published = svc.publishSummary(e.id, { revision: revision.number }, actor);
-- return { summary: q.summary.get(e.id), revision: summaryRevisionView(q.summary.get(e.id), revision, e.id), published: !!published };
++ if (input.publish === true || input.publish === 'true' || input.publish === '1') published = await svc.publishSummary(e.id, { revision: revision.number }, actor);
++ return { summary: await q.summary.get(e.id), revision: await summaryRevisionView(await q.summary.get(e.id), revision, e.id), published: !!published };
});
},
@@@ -1409,138 +1409,138 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
* approves it, and it can never state a rating: its text is checked, and summaries have no
* field that could carry one.
*/
-- proposeSummary(ref, input = {}, actor) {
++ async proposeSummary(ref, input = {}, actor) {
if (!actor || (actor.kind !== 'service' && actor.kind !== 'system')) fail(403, 'summary.service_only', 'AI proposals come from a service principal');
const wf = input.workflow || {};
const runId = wf.run_id || wf.runId;
if (!wf.id || !runId) fail(400, 'authorship.workflow_required', `An AI proposal names its OpenVibe.AI workflow (workflow.id, e.g. ${SUMMARY_WORKFLOW}) and run (workflow.run_id)`);
let rec;
try { rec = authorship.record({ mode: 'ai', workflow: { id: String(wf.id), runId: String(runId), version: wf.version, model: wf.model }, stubProvider: !!(input.stub_provider || input.stubProvider) }); } catch (err) { fail(422, 'authorship.invalid', err.message); }
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
-- const body = checkSummaryInput(e.id, input, { ai: true });
-- const summary = ensureSummary(e.id);
-- const head = revisions.headNumber(summary.id);
-- const { revision } = revisions.create({
++ const body = await checkSummaryInput(e.id, input, { ai: true });
++ const summary = await ensureSummary(e.id);
++ const head = await revisions.headNumber(summary.id);
++ const { revision } = await revisions.create({
entityId: summary.id, expectedRevision: head, content: body.content, fields: body.fields,
meta: { authorship: rec }, author: actorId(actor), message: input.note ? `AI proposal: ${String(input.note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
});
-- writeCitations(summary, revision);
-- audit(actor, 'summary.proposed', e.id, summary.id, { revision: revision.number, workflow: rec.workflow, stub_provider: !!rec.stubProvider });
-- return { summary: q.summary.get(e.id), revision: summaryRevisionView(q.summary.get(e.id), revision, e.id) };
++ await writeCitations(summary, revision);
++ await audit(actor, 'summary.proposed', e.id, summary.id, { revision: revision.number, workflow: rec.workflow, stub_provider: !!rec.stubProvider });
++ return { summary: await q.summary.get(e.id), revision: await summaryRevisionView(await q.summary.get(e.id), revision, e.id) };
});
},
/** An editor approves (and by default publishes) or rejects a revision. */
-- reviewSummary(ref, n, { decision, note = null, publish = true } = {}, actor) {
++ async reviewSummary(ref, n, { decision, note = null, publish = true } = {}, actor) {
const who = requireEditorPerson(actor, 'Reviewing a summary');
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
-- return tx(() => {
-- const e = entityOrFail(ref);
-- const summary = q.summary.get(e.id);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
++ const summary = await q.summary.get(e.id);
if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
-- const rev = revisions.get(summary.id, Number(n));
++ const rev = await revisions.get(summary.id, Number(n));
if (!rev) fail(404, 'revision.not_found', `No revision ${n}`);
-- const review = reviews.record({ entityId: summary.id, revision: rev.number, reviewer: who, decision, note });
-- audit(actor, `summary.${decision}`, e.id, summary.id, { revision: rev.number, note });
++ const review = await reviews.record({ entityId: summary.id, revision: rev.number, reviewer: who, decision, note });
++ await audit(actor, `summary.${decision}`, e.id, summary.id, { revision: rev.number, note });
let published = false;
-- if (decision === 'approved' && publish !== false && publish !== 'false') { svc.publishSummary(e.id, { revision: rev.number }, actor); published = true; }
-- if (decision === 'approved' && !published && summary.state === 'published' && summary.published_revision === rev.number) syncEntity(e.id);
-- return { review, published, summary: q.summary.get(e.id) };
++ if (decision === 'approved' && publish !== false && publish !== 'false') { await svc.publishSummary(e.id, { revision: rev.number }, actor); published = true; }
++ if (decision === 'approved' && !published && summary.state === 'published' && summary.published_revision === rev.number) await syncEntity(e.id);
++ return { review, published, summary: await q.summary.get(e.id) };
});
},
-- publishSummary(ref, { revision } = {}, actor) {
++ async publishSummary(ref, { revision } = {}, actor) {
requireEditorPerson(actor, 'Publishing a summary');
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
-- const summary = q.summary.get(e.id);
++ const summary = await q.summary.get(e.id);
if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
-- const n = revision == null ? revisions.headNumber(summary.id) : Number(revision);
-- const rev = revisions.get(summary.id, n);
++ const n = revision == null ? await revisions.headNumber(summary.id) : Number(revision);
++ const rev = await revisions.get(summary.id, n);
if (!rev) fail(404, 'revision.not_found', `No revision ${revision}`);
-- const review = reviews.latest(summary.id, n);
++ const review = await reviews.latest(summary.id, n);
if (review && review.decision === 'rejected') fail(409, 'summary.rejected', 'This revision was rejected by an editor');
const rec = rev.meta && rev.meta.authorship;
if (rec) { const ok = authorship.canPublish(rec, review); if (!ok.ok) fail(409, ok.reason, 'AI-drafted summaries are published only after a person approves them'); }
if (rev.meta && rev.meta.system && !(review && review.decision === 'approved')) fail(409, 'summary.review_required', 'A revision prepared after a source change is published only after an editor approves it');
-- const bad = citationState(e.id, rev).filter((p) => !p.supported);
++ const bad = (await citationState(e.id, rev)).filter((p) => !p.supported);
if (bad.length) fail(409, 'summary.unsupported_points', `${bad.length} point(s) cite no live signal of this entity`, { points: bad.map((p) => p.key) });
const wasPublished = summary.state === 'published';
const before = summary.published_revision;
-- q.publishSummary.run({ id: summary.id, n, now: now() });
-- audit(actor, 'summary.published', e.id, summary.id, { revision: n, previous: before });
-- touchEntity(e.id);
-- const doc = syncEntity(e.id);
-- const fresh = q.entity.get(e.id);
-- const decision = decide(fresh);
++ await q.publishSummary.run({ id: summary.id, n, now: now() });
++ await audit(actor, 'summary.published', e.id, summary.id, { revision: n, previous: before });
++ await touchEntity(e.id);
++ const doc = await syncEntity(e.id);
++ const fresh = await q.entity.get(e.id);
++ const decision = await decide(fresh);
const action = !wasPublished ? 'published' : (before !== n ? 'updated' : null);
if (action) {
-- emit(hooks.publicationEvent({
++ await emit(hooks.publicationEvent({
product: 'reviews', type: 'summary', action, id: summary.id, revision: n,
actor: hooks.subjectRef(actorId(actor)), decision, now: now(),
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: false, visibility: 'public', canonical_url: entityUrl(fresh), publication_state: 'published', indexability: hooks.searchIndexability(decision) },
extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null, ...(rev.meta && rev.meta.correction ? { correction: { note: rev.meta.correction.note, corrects: rev.meta.correction.corrects } } : {}) },
}));
}
-- return q.summary.get(e.id);
++ return await q.summary.get(e.id);
});
},
-- unpublishSummary(ref, actor) {
++ async unpublishSummary(ref, actor) {
requireEditorPerson(actor, 'Unpublishing a summary');
-- return tx(() => {
-- const e = entityOrFail(ref);
-- const summary = q.summary.get(e.id);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
++ const summary = await q.summary.get(e.id);
if (!summary || summary.state !== 'published') fail(409, 'summary.not_published', 'Nothing is published');
-- q.unpublishSummary.run(now(), summary.id);
-- audit(actor, 'summary.unpublished', e.id, summary.id, { revision: summary.published_revision });
-- const doc = syncEntity(e.id);
-- const fresh = q.entity.get(e.id);
-- emit(hooks.publicationEvent({
++ await q.unpublishSummary.run(now(), summary.id);
++ await audit(actor, 'summary.unpublished', e.id, summary.id, { revision: summary.published_revision });
++ const doc = await syncEntity(e.id);
++ const fresh = await q.entity.get(e.id);
++ await emit(hooks.publicationEvent({
product: 'reviews', type: 'summary', action: 'unpublished', id: summary.id, revision: summary.published_revision,
-- actor: hooks.subjectRef(actorId(actor)), decision: decide(fresh), now: now(),
++ actor: hooks.subjectRef(actorId(actor)), decision: await decide(fresh), now: now(),
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: true },
extra: { entity_id: e.id, entity_slug: fresh.slug },
}));
-- return q.summary.get(e.id);
++ return await q.summary.get(e.id);
});
},
-- flaggedSummaries() { return q.flaggedSummaries.all().map((s) => ({ ...s, entity: q.entity.get(s.entity_id) })); },
-- pendingSummaries() {
-- return q.allSummaries.all().map((s) => ({ summary: s, entity: q.entity.get(s.entity_id), pending: pendingRevisions(s) }))
++ async flaggedSummaries() { return (await Promise.all((await q.flaggedSummaries.all()).map(async (s) => ({ ...s, entity: await q.entity.get(s.entity_id) })))); },
++ async pendingSummaries() {
++ return (await Promise.all((await q.allSummaries.all()).map(async (s) => ({ summary: s, entity: await q.entity.get(s.entity_id), pending: await pendingRevisions(s) }))))
.filter((x) => x.pending.length && x.entity && x.entity.state === 'active')
.map((x) => ({ entity: x.entity, summary: x.summary, pending: x.pending.map((p) => ({ number: p.rev.number, status: p.status, message: p.rev.message, created_at: p.rev.createdAt })) }));
},
/** Published summaries for the feed, newest first, with the gate's decision for the entity. */
-- recentSummaries(limit = 50) {
-- return q.publishedSummaries.all(limit).map((s) => {
-- const e = q.entity.get(s.entity_id);
++ async recentSummaries(limit = 50) {
++ return (await Promise.all((await q.publishedSummaries.all(limit)).map(async (s) => {
++ const e = await q.entity.get(s.entity_id);
if (!e || e.state !== 'active') return null;
-- const rev = revisions.get(s.id, s.published_revision);
-- return { summary: s, entity: e, rev, decision: decide(e) };
-- }).filter(Boolean);
++ const rev = await revisions.get(s.id, s.published_revision);
++ return { summary: s, entity: e, rev, decision: await decide(e) };
++ }))).filter(Boolean);
},
/** Every active entity with its decision (sitemaps). */
-- publicEntities() {
-- return db.prepare("SELECT * FROM review_entities WHERE state = 'active' ORDER BY id").all().map((e) => ({ entity: e, decision: decide(e) }));
++ async publicEntities() {
++ return (await Promise.all((await db.prepare("SELECT * FROM review_entities WHERE state = 'active' ORDER BY id").all()).map(async (e) => ({ entity: e, decision: await decide(e) }))));
},
/** Entities with at least one live signal or a published summary (the home page). */
-- entitiesWithData(limit = 60) {
-- return db.prepare(`SELECT e.* FROM review_entities e WHERE e.state = 'active' AND (
++ async entitiesWithData(limit = 60) {
++ return (await Promise.all((await db.prepare(`SELECT e.* FROM review_entities e WHERE e.state = 'active' AND (
EXISTS (SELECT 1 FROM review_signals s JOIN review_entities m ON m.id = s.entity_id WHERE s.status = 'active' AND (m.id = e.id OR m.merged_into = e.id))
OR EXISTS (SELECT 1 FROM review_summaries u WHERE u.entity_id = e.id AND u.state = 'published'))
-- ORDER BY e.updated_at DESC LIMIT ?`).all(limit).map((e) => ({ entity: e, aggregate: aggregateView(q.lastAggregate.get(e.id)) }));
++ ORDER BY e.updated_at DESC LIMIT ?`).all(limit)).map(async (e) => ({ entity: e, aggregate: aggregateView(await q.lastAggregate.get(e.id)) }))));
},
// Corrections (reviews.correction.submit) --------------------------------------------
-- submitCorrection(ref, { target_type: targetType = 'entity', target_id: targetId = null, body, evidence_url: evidenceUrl = null } = {}, actor) {
++ async submitCorrection(ref, { target_type: targetType = 'entity', target_id: targetId = null, body, evidence_url: evidenceUrl = null } = {}, actor) {
if (!access.isPerson(actor)) fail(403, 'reviews.person_required', 'Corrections come from a signed-in person (a service must name them in X-OV-Subject)');
if (!['entity', 'alias', 'signal', 'summary', 'aggregate'].includes(targetType)) fail(422, 'correction.invalid', 'target_type is entity, alias, signal, summary or aggregate');
const text = String(body == null ? '' : body).trim();
@@@ -1550,62 +1550,62 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (evidenceUrl) {
try { const u = new URL(String(evidenceUrl)); if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('x'); url = u.toString(); } catch { fail(422, 'correction.invalid', 'evidence_url must be an http(s) URL'); }
}
-- return tx(() => {
-- const e = entityOrFail(ref);
++ return await tx(async () => {
++ const e = await entityOrFail(ref);
if (e.state === 'deleted') fail(410, 'entity.deleted', 'This entity was deleted');
const t = now();
-- const entityId = canonicalOf(e.id) || e.id;
++ const entityId = await canonicalOf(e.id) || e.id;
// One person cannot bury the editors' queue: a daily allowance, and the same open request once.
-- if (q.sameOpenCorrection.get(actor.subject, entityId, text)) fail(409, 'correction.duplicate', 'You already sent this correction; it is waiting for an editor');
-- if (q.correctionsSince.get(actor.subject, t - DAY_MS).n >= CORRECTIONS_PER_PERSON_DAY) fail(429, 'correction.rate_limited', `At most ${CORRECTIONS_PER_PERSON_DAY} corrections a day; the editors will read the ones you sent`);
++ if (await q.sameOpenCorrection.get(actor.subject, entityId, text)) fail(409, 'correction.duplicate', 'You already sent this correction; it is waiting for an editor');
++ if ((await q.correctionsSince.get(actor.subject, t - DAY_MS)).n >= CORRECTIONS_PER_PERSON_DAY) fail(429, 'correction.rate_limited', `At most ${CORRECTIONS_PER_PERSON_DAY} corrections a day; the editors will read the ones you sent`);
const id = `cor_${ulid(t)}`;
const target = targetId ? String(targetId).slice(0, 100) : null;
-- q.insertCorrection.run({ id, entity_id: entityId, target_type: targetType, target_id: target, body: text, evidence_url: url ? url.slice(0, 2000) : null, submitted_by: actor.subject, via: actor.kind === 'service' ? actor.service : null, now: t });
-- audit(actor, 'correction.submitted', entityId, id, { target_type: targetType, target_id: target });
-- return q.correction.get(id);
++ await q.insertCorrection.run({ id, entity_id: entityId, target_type: targetType, target_id: target, body: text, evidence_url: url ? url.slice(0, 2000) : null, submitted_by: actor.subject, via: actor.kind === 'service' ? actor.service : null, now: t });
++ await audit(actor, 'correction.submitted', entityId, id, { target_type: targetType, target_id: target });
++ return await q.correction.get(id);
});
},
-- openCorrections() {
-- return q.openCorrections.all().map((c) => {
-- const entity = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);
-- const s = entity && entity.state === 'active' ? q.summary.get(entity.id) : null;
++ async openCorrections() {
++ return (await Promise.all((await q.openCorrections.all()).map(async (c) => {
++ const entity = await q.entity.get(await canonicalOf(c.entity_id) || c.entity_id);
++ const s = entity && entity.state === 'active' ? await q.summary.get(entity.id) : null;
return { ...c, entity, summary_published: !!(s && s.state === 'published') };
-- });
++ })));
},
-- correction(id) { return q.correction.get(String(id)) || null; },
++ async correction(id) { return await q.correction.get(String(id)) || null; },
/**
* An editor accepts or rejects a correction request. `note` stays with the editors. Accepting
* a request about an entity whose summary is published corrects that summary: a new revision
* with the public `correction_note` (and the new text in `summary`, or the published text
* carried forward). Rejecting creates nothing.
*/
-- resolveCorrection(id, { status, note = null, correction_note: correctionNote = null, summary: body = null } = {}, actor) {
++ async resolveCorrection(id, { status, note = null, correction_note: correctionNote = null, summary: body = null } = {}, actor) {
const who = requireEditorPerson(actor, 'Resolving a correction');
if (!['accepted', 'rejected'].includes(status)) fail(422, 'correction.invalid', 'status is accepted or rejected');
if (body != null && (typeof body !== 'object' || Array.isArray(body))) fail(422, 'summary.invalid', 'summary is { overview, overview_signals, pros, cons }');
-- return tx(() => {
-- const c = q.correction.get(String(id));
++ return await tx(async () => {
++ const c = await q.correction.get(String(id));
if (!c) fail(404, 'correction.not_found', 'No such correction');
if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);
const internal = note ? String(note).slice(0, 2000) : null;
if (status === 'accepted') {
-- const e = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);
-- const s = e && e.state === 'active' ? q.summary.get(e.id) : null;
++ const e = await q.entity.get(await canonicalOf(c.entity_id) || c.entity_id);
++ const s = e && e.state === 'active' ? await q.summary.get(e.id) : null;
if (s && s.state === 'published') {
-- const out = correctSummary(e, { ...(body || {}) }, { note: correctionNote, requestId: c.id, resolutionNote: internal }, actor, who);
++ const out = await correctSummary(e, { ...(body || {}) }, { note: correctionNote, requestId: c.id, resolutionNote: internal }, actor, who);
return { correction: out.correction, revision: out.revision };
}
}
-- q.resolveCorrection.run(status, who, internal, now(), c.id);
-- audit(actor, `correction.${status}`, c.entity_id, c.id, { note: internal });
-- return { correction: q.correction.get(c.id), revision: null };
++ await q.resolveCorrection.run(status, who, internal, now(), c.id);
++ await audit(actor, `correction.${status}`, c.entity_id, c.id, { note: internal });
++ return { correction: await q.correction.get(c.id), revision: null };
});
},
// Discussion (Community, referenced) -------------------------------------------------
-- knownThread(entityId) { const d = discussions.get(entityId); return d ? d.threadId : null; },
++ async knownThread(entityId) { const d = await discussions.get(entityId); return d ? d.threadId : null; },
async discussionThread(e, client) {
-- const known = discussions.get(e.id);
++ const known = await discussions.get(e.id);
if (known) return known.threadId;
if (e.state !== 'active') return null;
const out = await discussions.threadFor(e.id, { service: 'reviews', type: 'entity', id: e.id, label: e.name.slice(0, 200) }, { client });
@@@ -1613,27 +1613,27 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
},
// Search reconciliation ---------------------------------------------------------------
-- reconcileIndex() {
-- return tx(() => {
++ async reconcileIndex() {
++ return await tx(async () => {
let sent = 0;
-- for (const { id } of q.allEntityIds.all()) {
-- if (sequencer.current('reviews', 'entity', id) == null && q.entity.get(id).state !== 'active') continue;
-- const before = sequencer.current('reviews', 'entity', id);
-- const doc = syncEntity(id);
++ for (const { id } of await q.allEntityIds.all()) {
++ if (await sequencer.current('reviews', 'entity', id) == null && (await q.entity.get(id)).state !== 'active') continue;
++ const before = await sequencer.current('reviews', 'entity', id);
++ const doc = await syncEntity(id);
if (doc && doc.revision !== before) sent++;
}
return { sent };
});
},
-- stats() {
-- const one = (sql) => db.prepare(sql).get().n;
++ async stats() {
++ const one = async (sql) => (await db.prepare(sql).get()).n;
return {
-- entities: one("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active'"),
-- signals: one("SELECT COUNT(*) AS n FROM review_signals WHERE status = 'active'"),
-- items_unresolved: one("SELECT COUNT(*) AS n FROM review_source_items WHERE resolution IN ('ambiguous','unmatched') AND state = 'active'"),
-- corrections_open: one("SELECT COUNT(*) AS n FROM review_corrections WHERE status = 'open'"),
-- summaries_flagged: one('SELECT COUNT(*) AS n FROM review_summaries WHERE flagged = 1'),
++ entities: await one("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active'"),
++ signals: await one("SELECT COUNT(*) AS n FROM review_signals WHERE status = 'active'"),
++ items_unresolved: await one("SELECT COUNT(*) AS n FROM review_source_items WHERE resolution IN ('ambiguous','unmatched') AND state = 'active'"),
++ corrections_open: await one("SELECT COUNT(*) AS n FROM review_corrections WHERE status = 'open'"),
++ summaries_flagged: await one('SELECT COUNT(*) AS n FROM review_summaries WHERE flagged = 1'),
};
},
};
ddiff --git a/server/reviews/sync.js b/server/reviews/sync.js
iindex fa384c1..3d4838b 100644
---- a/server/reviews/sync.js
++++ b/server/reviews/sync.js
@@@ -27,8 +27,8 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
pending: db.prepare('SELECT COUNT(*) AS n FROM review_import_queue'),
};
const system = { kind: 'system', service: 'svc:reviews' };
-- const state = (name) => { const r = q.get.get(name); return r ? r.value : null; };
-- const setState = (name, value) => q.put.run(name, value == null ? null : String(value), now());
++ const state = async (name) => { const r = await q.get.get(name); return r ? r.value : null; };
++ const setState = async (name, value) => await q.put.run(name, value == null ? null : String(value), now());
let pulling = null;
let draining = null;
@@@ -36,7 +36,7 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
async function pullOnce({ maxPages = 25 } = {}) {
if (!platform.sources.configured) return { ok: false, reason: 'OpenVibe.Sources is not configured' };
const counts = { pages: 0, applied: 0, unchanged: 0, skipped: 0 };
-- let after = Number(state(CURSOR) || 0);
++ let after = Number(await state(CURSOR) || 0);
const known = new Set();
const infos = new Map();
try {
@@@ -46,27 +46,27 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
for (const key of new Set(body.items.map((i) => i.source_key))) {
if (known.has(key)) continue;
known.add(key);
-- if (db.prepare('SELECT 1 FROM review_sources WHERE key = ?').get(key)) continue;
++ if (await db.prepare('SELECT 1 FROM review_sources WHERE key = ?').get(key)) continue;
try { const full = await platform.sources.getSource(key); if (full) infos.set(key, full); } catch { /* notes still come with the item */ }
}
-- svc.batch(() => {
++ await svc.batch(async () => {
for (const item of body.items) {
const info = { ...(infos.get(item.source_key) || {}), ...((body.sources && body.sources[item.source_key]) || {}) };
try {
-- const out = svc.applyItem(item, { sourceInfo: info, actor: system });
++ const out = await svc.applyItem(item, { sourceInfo: info, actor: system });
if (out.outcome === 'unchanged') counts.unchanged++; else counts.applied++;
} catch (err) {
if (err && err.status && err.status < 500) { counts.skipped++; log.warn(`[Reviews] item ${item && item.id} skipped: ${err.message}`); } else throw err;
}
}
});
-- if (Number.isInteger(body.next_after) && body.next_after >= after) { after = body.next_after; setState(CURSOR, after); }
++ if (Number.isInteger(body.next_after) && body.next_after >= after) { after = body.next_after; await setState(CURSOR, after); }
if (!body.more) break;
}
-- setState('sources.last_pull_ok_at', new Date(now()).toISOString());
++ await setState('sources.last_pull_ok_at', new Date(now()).toISOString());
return { ok: true, after, ...counts };
} catch (err) {
-- setState('sources.last_pull_error', `${new Date(now()).toISOString()} ${String(err && err.message).slice(0, 300)}`);
++ await setState('sources.last_pull_error', `${new Date(now()).toISOString()} ${String(err && err.message).slice(0, 300)}`);
return { ok: false, reason: err && err.message, after, ...counts };
}
}
@@@ -75,15 +75,15 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
if (!platform.sources.configured) return { ok: false, reason: 'OpenVibe.Sources is not configured', done: 0 };
let done = 0;
let failed = 0;
-- for (const row of q.due.all(now(), limit)) {
++ for (const row of await q.due.all(now(), limit)) {
try {
await svc.importItem(row.item_id, platform.sources, system);
-- q.done.run(row.item_id);
++ await q.done.run(row.item_id);
done++;
} catch (err) {
// A 404/422 is final (the item is gone or unusable); anything else is retried.
-- if (err && (err.status === 404 || err.status === 422)) { q.done.run(row.item_id); log.warn(`[Reviews] queued item ${row.item_id} dropped: ${err.message}`); continue; }
-- q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], String(err && err.message).slice(0, 500), row.item_id);
++ if (err && (err.status === 404 || err.status === 422)) { await q.done.run(row.item_id); log.warn(`[Reviews] queued item ${row.item_id} dropped: ${err.message}`); continue; }
++ await q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], String(err && err.message).slice(0, 500), row.item_id);
failed++;
}
}
@@@ -91,9 +91,9 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
}
return {
-- enqueue(itemId, reason) { q.enqueue.run(String(itemId), String(reason || 'event').slice(0, 60), now()); },
-- pending: () => q.pending.get().n,
-- cursor: () => Number(state(CURSOR) || 0),
++ async enqueue(itemId, reason) { await q.enqueue.run(String(itemId), String(reason || 'event').slice(0, 60), now()); },
++ pending: async () => (await q.pending.get()).n,
++ cursor: async () => Number(await state(CURSOR) || 0),
state,
pull(opts) { if (!pulling) pulling = pullOnce(opts).finally(() => { pulling = null; }); return pulling; },
drain(opts) { if (!draining) draining = drainQueue(opts).finally(() => { draining = null; }); return draining; },
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 3209e1b..ee7385f 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -53,15 +53,15 @@ const H = require('./helpers');
// entity edits: 30 a minute per editor, shared by the API and the form; nothing is stored past it
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
-- const count = () => h.db.prepare('SELECT COUNT(*) AS n FROM review_entities').get().n;
++ const count = async () => (await h.db.prepare('SELECT COUNT(*) AS n FROM review_entities').get()).n;
for (let i = 0; i < 30; i++) {
r = await H.req(h, 'POST', '/api/v1/entities', { token: H.editorToken(), body: { name: `Limits entity ${i}`, kind: 'product' } });
assert.strictEqual(r.status, 201, `entity ${i + 1}: ${r.text}`);
}
-- const before = count();
++ const before = await count();
r = await H.req(h, 'POST', '/editor/entities/new', { cookie: H.cookieFor(H.editorToken()), form: { name: 'One too many', kind: 'product' } });
assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
-- assert.strictEqual(count(), before, 'nothing stored');
++ assert.strictEqual(await count(), before, 'nothing stored');
r = await H.req(h, 'POST', '/api/v1/entities', { token: chief, body: { name: 'Another editor', kind: 'product' } });
assert.strictEqual(r.status, 201, `another editor still writes: ${r.text}`);
ddiff --git a/test/corrections.test.js b/test/corrections.test.js
iindex 2127603..1c95817 100644
---- a/test/corrections.test.js
++++ b/test/corrections.test.js
@@@ -15,7 +15,7 @@ const {
const t = suite('corrections');
const noScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
--const revCount = (h, summaryId) => h.db.prepare('SELECT COUNT(*) AS n FROM review_summary_revisions WHERE entity_id = ?').get(summaryId).n;
++const revCount = async (h, summaryId) => (await h.db.prepare('SELECT COUNT(*) AS n FROM review_summary_revisions WHERE entity_id = ?').get(summaryId)).n;
const OLD = 'Every sampled Steam reviewer recommends it without reservation.';
const NEW = 'Three of the four sampled Steam reviewers recommend it; one does not.';
@@@ -26,7 +26,7 @@ const INTERNAL = 'internal: checked the signal table, reader is right';
async function setup(h) {
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
for (const v of [true, true, true, false]) await importItem(h, steamItem({ votedUp: v }));
-- const sigs = h.db.prepare("SELECT id, recommended FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);
++ const sigs = await h.db.prepare("SELECT id, recommended FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);
const pos = sigs.filter((s) => s.recommended === 1).map((s) => s.id);
const neg = sigs.filter((s) => s.recommended === 0).map((s) => s.id);
const w = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
@@@ -40,7 +40,7 @@ async function setup(h) {
async function submit(h, e, body = REQUEST) {
const r = await req(h, 'POST', `/e/${e.slug}/correct`, { cookie: cookieFor(readerToken()), form: { target_type: 'summary', body, evidence_url: 'https://store.steampowered.com/app/620/' } });
assert.strictEqual(r.status, 201, r.text.slice(0, 300));
-- return h.db.prepare('SELECT * FROM review_corrections WHERE body = ?').get(body);
++ return await h.db.prepare('SELECT * FROM review_corrections WHERE body = ?').get(body);
}
t('accepting a correction publishes a new revision with its note; the page shows it and the history without JavaScript', async () => {
@@@ -66,8 +66,8 @@ t('accepting a correction publishes a new revision with its note; the page shows
const bare = await patch(editorToken(), { status: 'accepted' });
assert.strictEqual(bare.status, 422);
assert.strictEqual(bare.json.code, 'correction.note_required');
-- assert.strictEqual(revCount(h, summaryId), 1);
-- assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c.id).status, 'open');
++ assert.strictEqual(await revCount(h, summaryId), 1);
++ assert.strictEqual((await h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c.id)).status, 'open');
const ok = await patch(editorToken(), {
status: 'accepted', note: INTERNAL, correction_note: NOTE,
@@@ -82,10 +82,10 @@ t('accepting a correction publishes a new revision with its note; the page shows
assert.strictEqual(ok.json.revision.author, EDITOR, 'who: the editor who corrected it');
// Immutable revisions: the old text is still revision 1; the correction is revision 2.
-- const rows = h.db.prepare('SELECT number, content, meta, author FROM review_summary_revisions WHERE entity_id = ? ORDER BY number').all(summaryId);
++ const rows = await h.db.prepare('SELECT number, content, meta, author FROM review_summary_revisions WHERE entity_id = ? ORDER BY number').all(summaryId);
assert.deepStrictEqual(rows.map((r) => [r.number, r.content]), [[1, OLD], [2, NEW]]);
-- assert.strictEqual(JSON.parse(rows[1].meta).correction.note, NOTE);
-- assert.throws(() => h.db.prepare('UPDATE review_summary_revisions SET content = ? WHERE entity_id = ? AND number = 2').run('rewritten', summaryId));
++ assert.strictEqual((typeof rows[1].meta === 'string' ? JSON.parse(rows[1].meta) : rows[1].meta).correction.note, NOTE);
++ await assert.rejects(async () => await h.db.prepare('UPDATE review_summary_revisions SET content = ? WHERE entity_id = ? AND number = 2').run('rewritten', summaryId));
// The public page, without JavaScript: the corrected text, the note, and the history.
const page = await req(h, 'GET', `/e/${e.slug}`);
@@@ -112,7 +112,7 @@ t('accepting a correction publishes a new revision with its note; the page shows
assert.ok(review.dateModified, 'the Review says it was modified');
const feed = JSON.parse((await req(h, 'GET', '/feed.json')).text);
assert.ok(feed.items[0].summary.startsWith(`Correction: ${NOTE}\n${NEW}`), JSON.stringify(feed.items[0]));
-- const events = outbox(h);
++ const events = await outbox(h);
const upd = events.filter((x) => x.event_type === 'reviews.summary.updated');
assert.strictEqual(upd.length, 1);
assert.deepStrictEqual(upd[0].payload.correction, { note: NOTE, corrects: 1 });
@@@ -149,20 +149,20 @@ t('a rejected correction creates no revision; editors correct with plain forms,
assert.match(desk, /name="correction_note"/, 'the desk asks for the public note when a summary is published');
const rej = await req(h, 'POST', `/editor/corrections/${c1.id}`, { cookie: ed, form: { status: 'rejected', note: 'opinion, not an error', correction_note: 'ignored when rejecting' } });
assert.strictEqual(rej.status, 303);
-- assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c1.id).status, 'rejected');
-- assert.strictEqual(revCount(h, summaryId), 1, 'rejecting creates no revision');
-- assert.ok(!outbox(h).some((x) => x.event_type === 'reviews.summary.updated'));
++ assert.strictEqual((await h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c1.id)).status, 'rejected');
++ assert.strictEqual(await revCount(h, summaryId), 1, 'rejecting creates no revision');
++ assert.ok(!(await outbox(h)).some((x) => x.event_type === 'reviews.summary.updated'));
// From the desk: accept with a note, the published text carried forward as a correction revision.
const c2 = await submit(h, e, 'The signal table lists a review that its source has since edited.');
const short = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'fixed' } });
assert.strictEqual(short.status, 422, 'a correction note is a sentence readers can use');
-- assert.strictEqual(revCount(h, summaryId), 1);
++ assert.strictEqual(await revCount(h, summaryId), 1);
const acc = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'Checked the cited signals against their sources; the text stands.' } });
assert.strictEqual(acc.status, 303);
-- const r2 = h.db.prepare('SELECT content, meta FROM review_summary_revisions WHERE entity_id = ? AND number = 2').get(summaryId);
++ const r2 = await h.db.prepare('SELECT content, meta FROM review_summary_revisions WHERE entity_id = ? AND number = 2').get(summaryId);
assert.strictEqual(r2.content, OLD, 'carried forward');
-- assert.strictEqual(JSON.parse(r2.meta).correction.note, 'Checked the cited signals against their sources; the text stands.');
++ assert.strictEqual((typeof r2.meta === 'string' ? JSON.parse(r2.meta) : r2.meta).correction.note, 'Checked the cited signals against their sources; the text stands.');
// From the edit page, answering a request: the form names it and accepts it on publish.
const c3 = await submit(h, e, 'Only three of the four sampled reviewers recommend it, not all of them.');
@@@ -174,8 +174,8 @@ t('a rejected correction creates no revision; editors correct with plain forms,
con_text_0: 'Not by everyone', con_signals_0: neg, correction_id: c3.id, correction_note: NOTE, expected_revision: '2', publish: '0',
} });
assert.strictEqual(fixed.status, 303, fixed.text.slice(0, 400));
-- assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c3.id).status, 'accepted');
-- const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
++ assert.strictEqual((await h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c3.id)).status, 'accepted');
++ const s = await h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
assert.strictEqual(s.published_revision, 3, 'a correction is published at once');
// An editor corrects without any request.
@@@ -203,7 +203,7 @@ t('a correction of approved AI text is AI-assisted, never "written by a person"'
try {
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
for (const v of [true, true, false]) await importItem(h, steamItem({ votedUp: v }));
-- const sigs = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all().map((s) => s.id);
++ const sigs = (await h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all()).map((s) => s.id);
const p = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: serviceToken({ client: 'ai', cap: ['reviews.summary.propose'] }), body: {
overview: 'All sampled reviewers recommend it.', overview_signals: sigs, workflow: { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ', version: 1 },
} });
ddiff --git a/test/db.js b/test/db.js
nnew file mode 100644
iindex 0000000..75fb46d
---- /dev/null
++++ b/test/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test boot (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * REVIEWS_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of its own.
++ */
++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../server/db');
++
++const testDb = ({ store = process.env.REVIEWS_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'reviews', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
iindex 86a689c..3deb682 100644
---- a/test/delegation.test.js
++++ b/test/delegation.test.js
@@@ -14,7 +14,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
const CAPS = ['reviews.entity.manage'];
const token = (sub, actorType, extra) => serviceToken({ sub, actorType, cap: CAPS, extra });
const entity = (name) => ({ name, kind: 'product' });
--const count = (h, name) => h.db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE name = ?').get(name).n;
++const count = async (h, name) => (await h.db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE name = ?').get(name)).n;
t('an app or module cannot act as an editor by naming them in X-OV-Subject', async () => {
const h = await boot();
@@@ -24,7 +24,7 @@ t('an app or module cannot act as an editor by naming them in X-OV-Subject', asy
const r = await req(h, 'POST', '/api/v1/entities', { token: token(sub, type, extra), headers: { 'X-OV-Subject': EDITOR }, body: entity(`Planted by ${type}`) });
assert.strictEqual(r.status, 403, `${type} ${JSON.stringify(extra)}: ${r.text}`);
assert.strictEqual(r.json.code, 'subject.not_delegated');
-- assert.strictEqual(count(h, `Planted by ${type}`), 0);
++ assert.strictEqual(await count(h, `Planted by ${type}`), 0);
}
}
} finally { await h.stop(); }
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 314a2fa..cde05d4 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -12,6 +12,7 @@ const path = require('path');
const { serviceAuth, ids } = require('openvibe-contracts');
const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const { load } = require('../server/config');
++const { testDb } = require('./db');
const { start } = require('../server/index');
const ISSUER = 'https://network.test';
@@@ -41,7 +42,7 @@ function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.reviews', exp =
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 = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
/** In-memory OpenVibe.Sources: items in change order, sources, removal. */
function fakeSources() {
@@@ -73,9 +74,9 @@ function fakeSources() {
let m = u.pathname.match(/^\/api\/v1\/items\/(itm_[0-9A-Z]+)$/);
if (m) {
const it = items.get(m[1]);
-- if (!it) return json(404, { code: 'sources.not_found' });
++ if (!it) return await json(404, { code: 'sources.not_found' });
const s = sources.get(it.source_key);
-- return json(200, { item: it, source: s ? { key: s.key, status: s.status, stale: s.stale, last_success_at: s.last_success_at } : null });
++ return await json(200, { item: it, source: s ? { key: s.key, status: s.status, stale: s.stale, last_success_at: s.last_success_at } : null });
}
if (u.pathname === '/api/v1/items') {
const after = Number(u.searchParams.get('after') || 0);
@@@ -84,11 +85,11 @@ function fakeSources() {
const page = all.slice(0, limit);
const srcs = {};
for (const i of page) { const s = sources.get(i.source_key); if (s) srcs[s.key] = { status: s.status, stale: s.stale, last_success_at: s.last_success_at }; }
-- return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: all.length > limit, sources: srcs });
++ return await json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: all.length > limit, sources: srcs });
}
m = u.pathname.match(/^\/api\/v1\/sources\/([a-z0-9-]+)$/);
-- if (m) { const s = sources.get(m[1]); return s ? json(200, { source: s }) : json(404, { code: 'sources.not_found' }); }
-- return json(404, { code: 'not_found' });
++ if (m) { const s = sources.get(m[1]); return s ? await json(200, { source: s }) : await json(404, { code: 'sources.not_found' }); }
++ return await json(404, { code: 'not_found' });
},
};
return api;
@@@ -130,16 +131,16 @@ function fakeCommunity() {
const ref = JSON.parse(opts.body).ref;
const key = `${ref.service}:${ref.type}:${ref.id}`;
if (!threads.has(key)) threads.set(key, { id: `thr_${threads.size + 1}`, comments: [] });
-- return json(201, { thread: { id: threads.get(key).id, visibility: 'public' }, created: true });
++ return await json(201, { thread: { id: threads.get(key).id, visibility: 'public' }, created: true });
}
const m = u.pathname.match(/^\/api\/v1\/comments\/threads\/([^/]+)(\/comments)?$/);
if (m) {
const t = [...threads.values()].find((x) => x.id === m[1]);
-- if (!t) return json(404, { code: 'thread.not_found' });
-- if (m[2]) { t.comments.push({ id: t.comments.length + 1, message: JSON.parse(opts.body).message, display_name: 'reader', created_at: '2026-09-21T10:00:00.000Z' }); return json(201, { comment: t.comments[t.comments.length - 1] }); }
-- return json(200, { thread: { id: t.id }, comments: t.comments });
++ if (!t) return await json(404, { code: 'thread.not_found' });
++ if (m[2]) { t.comments.push({ id: t.comments.length + 1, message: JSON.parse(opts.body).message, display_name: 'reader', created_at: '2026-09-21T10:00:00.000Z' }); return await json(201, { comment: t.comments[t.comments.length - 1] }); }
++ return await json(200, { thread: { id: t.id }, comments: t.comments });
}
-- return json(404, {});
++ return await json(404, {});
},
};
}
@@@ -165,12 +166,14 @@ async function boot({ env = {}, sources = fakeSources(), community = null, dbPat
if (host === 'community.test' && community) return community.fetch(url, opts);
throw new Error(`unexpected outbound fetch ${url}`);
};
++ // One database per boot (PGlite, or REVIEWS_TEST_STORE=pg: the containers), dropped when the boot stops.
++ const testdb = await testDb();
const h = await start({
-- config, publicKey, log, listen: true, workers, rateLimits, limitsNow, now, fetchImpl,
++ config, db: testdb.db, publicKey, log, listen: true, workers, rateLimits, limitsNow, now, fetchImpl,
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, sources, community };
++ return { ...h, stop: async () => { await h.stop(); await testdb.close(); }, base, dir, sources, community };
}
async function req(h, method, p, { token, body, form, headers = {}, cookie } = {}) {
@@@ -195,8 +198,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 review_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
++async function outbox(h) {
++ return (await h.db.prepare('SELECT envelope FROM review_event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
}
const IMPORT = () => serviceToken({ client: 'sources-sync', cap: ['reviews.signal.import'] });
@@@ -222,7 +225,7 @@ async function createEntity(h, body) {
async function deliver(h, envelope, { secret = WEBHOOK_SECRET, v1Only = false, now } = {}) {
const raw = JSON.stringify({ event: envelope, seq: 1 });
const headers = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret, { now });
-- return req(h, 'POST', '/internal/events', { body: raw, headers });
++ return await req(h, 'POST', '/internal/events', { body: raw, headers });
}
function sourcesEvent(type, item, extra = {}) {
ddiff --git a/test/merge.test.js b/test/merge.test.js
iindex eaa3e77..2fe710a 100644
---- a/test/merge.test.js
++++ b/test/merge.test.js
@@@ -10,15 +10,15 @@ const {
const t = suite('merge');
--function snapshot(h, ids) {
++async function snapshot(h, ids) {
const out = {};
for (const id of ids) {
out[id] = {
-- entity: h.db.prepare('SELECT id, slug, name, kind, state, merged_into FROM review_entities WHERE id = ?').get(id),
-- signals: h.db.prepare('SELECT id, entity_id, source_item_id, status, type, recommended, rating_value FROM review_signals WHERE entity_id = ? ORDER BY id').all(id),
-- aliases: h.db.prepare('SELECT id, type, norm, removed_at FROM review_entity_aliases WHERE entity_id = ? ORDER BY id').all(id),
-- items: h.db.prepare('SELECT id, entity_id, resolution FROM review_source_items WHERE entity_id = ? ORDER BY id').all(id),
-- aggregate: (() => { const r = h.db.prepare('SELECT result FROM review_aggregates WHERE entity_id = ? ORDER BY revision DESC LIMIT 1').get(id); return r ? r.result : null; })(),
++ entity: await h.db.prepare('SELECT id, slug, name, kind, state, merged_into FROM review_entities WHERE id = ?').get(id),
++ signals: await h.db.prepare('SELECT id, entity_id, source_item_id, status, type, recommended, rating_value FROM review_signals WHERE entity_id = ? ORDER BY id').all(id),
++ aliases: await h.db.prepare('SELECT id, type, norm, removed_at FROM review_entity_aliases WHERE entity_id = ? ORDER BY id').all(id),
++ items: await h.db.prepare('SELECT id, entity_id, resolution FROM review_source_items WHERE entity_id = ? ORDER BY id').all(id),
++ aggregate: await (async () => { const r = await h.db.prepare('SELECT result FROM review_aggregates WHERE entity_id = ? ORDER BY revision DESC LIMIT 1').get(id); return r ? r.result : null; })(),
};
}
return out;
@@@ -31,7 +31,7 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
const b = await createEntity(h, { name: 'Portal Two', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-two-dup' }] });
for (const v of [true, true, false]) await importItem(h, steamItem({ votedUp: v }));
for (const v of [true, false]) await importItem(h, steamItem({ votedUp: v, source: 'steam-reviews-portal-two-dup' }));
-- const before = snapshot(h, [a.id, b.id]);
++ const before = await snapshot(h, [a.id, b.id]);
assert.strictEqual(before[a.id].signals.length, 3);
assert.strictEqual(before[b.id].signals.length, 2);
@@@ -48,7 +48,7 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
assert.strictEqual(m.json.signals.length, 2);
// Nothing was rewritten: signals keep their entity; the target's aggregate spans both.
-- const during = snapshot(h, [a.id, b.id]);
++ const during = await snapshot(h, [a.id, b.id]);
assert.deepStrictEqual(during[b.id].signals, before[b.id].signals);
assert.deepStrictEqual(during[a.id].signals, before[a.id].signals);
assert.deepStrictEqual(during[b.id].aliases, before[b.id].aliases);
@@@ -64,12 +64,12 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
assert.strictEqual(res.json.via.id, b.id);
// A new item for B while merged is still attributed to B (and shown under A).
await importItem(h, steamItem({ votedUp: true, source: 'steam-reviews-portal-two-dup' }));
-- const late = h.db.prepare("SELECT entity_id FROM review_signals WHERE source_key = 'steam-reviews-portal-two-dup' ORDER BY created_at DESC LIMIT 1").get();
++ const late = await h.db.prepare("SELECT entity_id FROM review_signals WHERE source_key = 'steam-reviews-portal-two-dup' ORDER BY created_at DESC LIMIT 1").get();
assert.strictEqual(late.entity_id, b.id);
const s = await req(h, 'POST', `/api/v1/entities/${b.slug}/split`, { token: editorToken(), body: { note: 'Actually two different editions' } });
assert.strictEqual(s.status, 200, s.text);
-- const after = snapshot(h, [a.id, b.id]);
++ const after = await snapshot(h, [a.id, b.id]);
// A is exactly as before; B is as before plus the signal that arrived for B meanwhile.
assert.deepStrictEqual(after[a.id], before[a.id]);
assert.deepStrictEqual(after[b.id].entity, before[b.id].entity);
@@@ -78,15 +78,15 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
assert.strictEqual(after[b.id].signals.length, 3);
// Audited: the link row, the log and the events.
-- const link = h.db.prepare("SELECT * FROM review_entity_links WHERE type = 'merged_into'").get();
++ const link = await h.db.prepare("SELECT * FROM review_entity_links WHERE type = 'merged_into'").get();
assert.strictEqual(link.created_by, EDITOR);
assert.strictEqual(link.note, 'Same game, duplicate listing');
assert.strictEqual(link.ended_by, EDITOR);
assert.strictEqual(link.end_note, 'Actually two different editions');
-- const log = h.db.prepare("SELECT action, actor, detail FROM review_audit WHERE action IN ('entity.merged','entity.split') ORDER BY id").all();
++ const log = await h.db.prepare("SELECT action, actor, detail FROM review_audit WHERE action IN ('entity.merged','entity.split') ORDER BY id").all();
assert.deepStrictEqual(log.map((r) => r.action), ['entity.merged', 'entity.split']);
assert.deepStrictEqual(JSON.parse(log[0].detail).signals.sort(), before[b.id].signals.map((x) => x.id).sort());
-- const ev = outbox(h).filter((x) => /^reviews\.entity\./.test(x.event_type)).map((x) => x.event_type);
++ const ev = (await outbox(h)).filter((x) => /^reviews\.entity\./.test(x.event_type)).map((x) => x.event_type);
assert.deepStrictEqual(ev, ['reviews.entity.merged', 'reviews.entity.split']);
const hist = await req(h, 'GET', `/e/${a.slug}/history`);
assert.match(hist.text, /Portal Two<\/a> merged into/);
@@@ -119,15 +119,15 @@ t('a split flags the former target\'s summary when it cited signals that went ba
const b = await createEntity(h, { name: 'Widget Pro', kind: 'product', aliases: [{ type: 'url', value: 'https://shop.example/p/widget-pro' }] });
await importItem(h, productItem({ value: 4, best: 5, count: 10 }));
await importItem(h, productItem({ value: 3, best: 5, count: 10, url: 'https://shop.example/p/widget-pro', title: 'Widget Pro' }));
-- const sigB = h.db.prepare('SELECT id FROM review_signals WHERE entity_id = ?').get(b.id).id;
-- const sigA = h.db.prepare('SELECT id FROM review_signals WHERE entity_id = ?').get(a.id).id;
++ const sigB = (await h.db.prepare('SELECT id FROM review_signals WHERE entity_id = ?').get(b.id)).id;
++ const sigA = (await h.db.prepare('SELECT id FROM review_signals WHERE entity_id = ?').get(a.id)).id;
await req(h, 'POST', `/api/v1/entities/${b.slug}/merge`, { token: editorToken(), body: { into: a.slug, note: 'dup' } });
const w = await req(h, 'POST', `/api/v1/entities/${a.slug}/summary/revisions`, { token: editorToken(), body: {
overview: 'Two shop listings rate it.', pros: [{ text: 'Rated well at the first shop', signals: [sigA] }], cons: [{ text: 'Rated lower at the second listing', signals: [sigB] }], publish: true,
} });
assert.strictEqual(w.status, 201, w.text);
await req(h, 'POST', `/api/v1/entities/${b.slug}/split`, { token: editorToken(), body: { note: 'different products' } });
-- const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(a.id);
++ const s = await h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(a.id);
assert.strictEqual(s.flagged, 1);
assert.match(s.flag_reason, /split/);
const page = await req(h, 'GET', `/api/v1/entities/${a.slug}`, { token: editorToken() });
ddiff --git a/test/nojs.test.js b/test/nojs.test.js
iindex 587ba74..666923f 100644
---- a/test/nojs.test.js
++++ b/test/nojs.test.js
@@@ -70,7 +70,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
assert.match(noScripts(home0.text), /Nothing yet\./, 'nothing seeded');
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', description: 'A puzzle game.', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
for (const v of [true, false]) await importItem(h, steamItem({ votedUp: v }));
-- const sig = h.db.prepare('SELECT id, recommended FROM review_signals').all();
++ const sig = await h.db.prepare('SELECT id, recommended FROM review_signals').all();
let sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
assert.ok(!sm.text.includes(`/e/${e.slug}`), 'thin: not in the sitemap');
await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
@@@ -112,8 +112,8 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
const after = await req(h, 'GET', `/e/${e.slug}`, { cookie: cookieFor(readerToken()) });
assert.match(after.text, /Is the co-op counted here\?/);
// Referenced, not duplicated: the comment text exists in Community only.
-- for (const { name } of h.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()) {
-- for (const row of h.db.prepare(`SELECT * FROM "${name}"`).all()) assert.ok(!JSON.stringify(row).includes('co-op counted'), `comment text copied into ${name}`);
++ for (const { name } of await h.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()) {
++ for (const row of await h.db.prepare(`SELECT * FROM "${name}"`).all()) assert.ok(!JSON.stringify(row).includes('co-op counted'), `comment text copied into ${name}`);
}
} finally { await h.stop(); }
});
@@@ -140,9 +140,9 @@ t('editors work with plain forms: create, resolve an ambiguous item, summarise,
assert.match(form.text, /<form method="post" action="\/editor\/items\//);
const res = await req(h, 'POST', `/editor/items/${it.id}`, { cookie: ed, form: { action: 'resolve', entity: 'widget-3000', add_alias: 'url' } });
assert.strictEqual(res.status, 303);
-- const sig = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").get();
++ const sig = await h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").get();
assert.ok(sig, 'confirmed → signal');
-- assert.strictEqual(h.db.prepare('SELECT resolution_rule, resolved_by FROM review_source_items WHERE id = ?').get(it.id).resolution_rule, 'editor');
++ assert.strictEqual((await h.db.prepare('SELECT resolution_rule, resolved_by FROM review_source_items WHERE id = ?').get(it.id)).resolution_rule, 'editor');
const edit = await req(h, 'GET', '/e/widget-3000/edit', { cookie: ed });
assert.match(edit.text, new RegExp(`<option value="${sig.id}"`));
@@@ -169,7 +169,7 @@ t('editors work with plain forms: create, resolve an ambiguous item, summarise,
assert.strictEqual((await req(h, 'GET', '/e/widget-3000-classic/correct')).status, 401);
const cor = await req(h, 'POST', '/e/widget-3000-classic/correct', { cookie: cookieFor(readerToken()), form: { target_type: 'signal', target_id: sig.id, body: 'That listing is for the older model.' } });
assert.strictEqual(cor.status, 201);
-- assert.strictEqual(h.db.prepare("SELECT COUNT(*) AS n FROM review_corrections WHERE status = 'open'").get().n, 1);
++ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM review_corrections WHERE status = 'open'").get()).n, 1);
// Cross-site posts are refused.
const xs = await req(h, 'POST', '/e/widget-3000-classic/correct', { cookie: cookieFor(readerToken()), headers: { Origin: 'https://evil.example' }, form: { target_type: 'entity', body: 'cross-site attempt here' } });
assert.strictEqual(xs.status, 403);
ddiff --git a/test/proposals.test.js b/test/proposals.test.js
iindex 738a473..39e4862 100644
---- a/test/proposals.test.js
++++ b/test/proposals.test.js
@@@ -52,19 +52,21 @@ t('the service manifest proposal is a valid registry.service-manifest@1 and list
});
t('the nine authority tables exist; a fresh database holds no content', async () => {
-- const db = openDb(':memory:');
++ const fresh = await require('./db').testDb();
++ const db = fresh.db;
createStores(db);
-- const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
++ const tables = new Set((await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name));
assert.strictEqual(AUTHORITY_TABLES.length, 9);
for (const name of AUTHORITY_TABLES) assert.ok(tables.has(name), name);
for (const name of [...tables].filter((n) => n.startsWith('review_'))) {
-- assert.strictEqual(db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get().n, 0, `${name} is empty`);
++ assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get()).n, 0, `${name} is empty`);
}
++ await fresh.close();
// Booting the service seeds nothing either.
const h = await boot();
try {
for (const name of ['review_entities', 'review_signals', 'review_summaries', 'review_aggregates', 'review_sources', 'review_source_items']) {
-- assert.strictEqual(h.db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get().n, 0, name);
++ assert.strictEqual((await h.db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get()).n, 0, name);
}
} finally { await h.stop(); }
assert.ok(!fs.existsSync(path.join(__dirname, '..', 'seeds')), 'no seed directory');
ddiff --git a/test/resolution.test.js b/test/resolution.test.js
iindex 562920f..7b7e21d 100644
---- a/test/resolution.test.js
++++ b/test/resolution.test.js
@@@ -59,9 +59,9 @@ t('strong identifiers resolve; names only propose candidates; conflicts wait for
// Re-attribution withdraws the old signal and creates a new one for the other entity.
const moved = await req(h, 'POST', `/api/v1/items/${nameOnly.item.id}/resolution`, { token: editorToken(), body: { entity: w.slug } });
assert.strictEqual(moved.status, 200, moved.text);
-- const sigs = h.db.prepare('SELECT entity_id, status FROM review_signals WHERE source_item_id = ? ORDER BY created_at').all(nameOnly.item.id);
++ const sigs = await h.db.prepare('SELECT entity_id, status FROM review_signals WHERE source_item_id = ? ORDER BY created_at').all(nameOnly.item.id);
assert.deepStrictEqual(sigs.map((s) => [s.entity_id, s.status]), [[w2.id, 'withdrawn'], [w.id, 'active']]);
-- const log = h.db.prepare("SELECT COUNT(*) AS n FROM review_audit WHERE action = 'item.resolved'").get().n;
++ const log = (await h.db.prepare("SELECT COUNT(*) AS n FROM review_audit WHERE action = 'item.resolved'").get()).n;
assert.strictEqual(log, 2);
// Strong identifiers are unique across entities.
ddiff --git a/test/signals.test.js b/test/signals.test.js
iindex 42b2a9b..a2480f6 100644
---- a/test/signals.test.js
++++ b/test/signals.test.js
@@@ -29,7 +29,7 @@ t('items without an entity wait unmatched; a source binding settles them; every
assert.strictEqual(out.item.resolution, 'unmatched');
assert.strictEqual(out.signal, null);
}
-- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM review_signals').get().n, 0);
++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM review_signals').get()).n, 0);
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
const agg = await aggregateOf(h, e.slug);
@@@ -48,12 +48,12 @@ t('items without an entity wait unmatched; a source binding settles them; every
assert.ok('license_note' in s.provenance);
}
// The table itself refuses a signal without provenance.
-- assert.throws(() => h.db.prepare("INSERT INTO review_signals (id, entity_id, source_item_id, source_key, item_revision, type, recommended, observed_at, created_by, created_at) VALUES ('sig_x', ?, 'itm_nope', 'steam-reviews-portal-2', 1, 'recommendation', 1, NULL, 'x', 0)").run(e.id));
++ await assert.rejects(async () => await h.db.prepare("INSERT INTO review_signals (id, entity_id, source_item_id, source_key, item_revision, type, recommended, observed_at, created_by, created_at) VALUES ('sig_x', ?, 'itm_nope', 'steam-reviews-portal-2', 1, 'recommendation', 1, NULL, 'x', 0)").run(e.id));
// Signal values and provenance are immutable.
-- assert.throws(() => h.db.prepare('UPDATE review_signals SET observed_at = ? WHERE id = ?').run('2020-01-01T00:00:00.000Z', page.json.signals[0].signal_id), /immutable/);
-- assert.throws(() => h.db.prepare('DELETE FROM review_signals WHERE id = ?').run(page.json.signals[0].signal_id), /never deleted/);
++ await assert.rejects(async () => await h.db.prepare('UPDATE review_signals SET observed_at = ? WHERE id = ?').run('2020-01-01T00:00:00.000Z', page.json.signals[0].signal_id), /immutable/);
++ await assert.rejects(async () => await h.db.prepare('DELETE FROM review_signals WHERE id = ?').run(page.json.signals[0].signal_id), /never deleted/);
// Review text is never kept.
-- for (const row of h.db.prepare('SELECT fields, title FROM review_source_items').all()) {
++ for (const row of await h.db.prepare('SELECT fields, title FROM review_source_items').all()) {
assert.ok(!/summary|review_text|body/.test(row.fields));
assert.strictEqual(row.title, null);
}
@@@ -67,7 +67,7 @@ t('an entity without signals has no aggregate (null, not 0) and no aggregate row
const agg = await aggregateOf(h, e.slug);
assert.strictEqual(agg.aggregate, null);
assert.strictEqual(agg.aggregate_revision, null);
-- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM review_aggregates').get().n, 0);
++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM review_aggregates').get()).n, 0);
} finally { await h.stop(); }
});
@@@ -96,10 +96,10 @@ t('source removal (webhook) withdraws the signal and the next aggregate revision
assert.strictEqual(agg.aggregate.result.components.rating.on_scale.value, 4);
assert.strictEqual(agg.aggregate.result.components.rating.count, 100);
assert.ok(!agg.aggregate.result.inputs.some((i) => i.source_item_id === b.id));
-- const sig = h.db.prepare('SELECT * FROM review_signals WHERE source_item_id = ?').get(b.id);
++ const sig = await h.db.prepare('SELECT * FROM review_signals WHERE source_item_id = ?').get(b.id);
assert.strictEqual(sig.status, 'withdrawn');
assert.match(sig.status_reason, /licence withdrawn/);
-- const ev = outbox(h).filter((x) => x.event_type === 'reviews.signal.removed');
++ const ev = (await outbox(h)).filter((x) => x.event_type === 'reviews.signal.removed');
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev[0].payload.source_item_id, b.id);
@@@ -111,7 +111,7 @@ t('source removal (webhook) withdraws the signal and the next aggregate revision
agg = await aggregateOf(h, e.slug);
assert.strictEqual(agg.aggregate, null, 'no qualifying signal → no aggregate');
assert.strictEqual(agg.aggregate_revision, rev + 2, 'the disappearance is itself a recorded revision');
-- const rows = h.db.prepare('SELECT revision, result FROM review_aggregates WHERE entity_id = ? ORDER BY revision').all(e.id);
++ const rows = await h.db.prepare('SELECT revision, result FROM review_aggregates WHERE entity_id = ? ORDER BY revision').all(e.id);
assert.strictEqual(rows[rows.length - 1].result, null);
} finally { await h.stop(); }
});
@@@ -135,7 +135,7 @@ t('an item update supersedes the signal; the pull path follows Sources in change
assert.strictEqual(again.json.pull.applied, 1);
agg = await aggregateOf(h, e.slug);
assert.strictEqual(agg.aggregate.result.components.recommendation.percent, 50);
-- const sigs = h.db.prepare('SELECT * FROM review_signals WHERE source_item_id = ? ORDER BY created_at').all(a.id);
++ const sigs = await h.db.prepare('SELECT * FROM review_signals WHERE source_item_id = ? ORDER BY created_at').all(a.id);
assert.deepStrictEqual(sigs.map((s) => s.status), ['superseded', 'active']);
assert.strictEqual(sigs[0].superseded_by, sigs[1].id);
assert.strictEqual(sigs[1].item_revision, 2);
@@@ -166,7 +166,7 @@ t('a Sources outage changes nothing and invents nothing', async () => {
// An event for an item Sources cannot serve right now is queued and retried, not faked.
const ev = await deliver(h, sourcesEvent('sources.item.created', steamItem()));
assert.strictEqual(ev.json.outcome, 'queued');
-- assert.strictEqual(h.sync.pending(), 1);
++ assert.strictEqual(await h.sync.pending(), 1);
} finally { await h.stop(); }
});
@@@ -177,7 +177,7 @@ t('every produced event is a valid events.event-envelope@1', async () => {
const it = steamItem({ votedUp: true });
await importItem(h, it);
await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(it.id), { reason: 'takedown' }));
-- const events = outbox(h);
++ const events = await outbox(h);
assert.ok(events.some((x) => x.event_type === 'reviews.signal.added'));
assert.ok(events.some((x) => x.event_type === 'reviews.signal.removed'));
assert.ok(events.some((x) => x.event_type === 'reviews.index_document.upserted'));
ddiff --git a/test/summaries.test.js b/test/summaries.test.js
iindex dd4d6f3..f6ec792 100644
---- a/test/summaries.test.js
++++ b/test/summaries.test.js
@@@ -18,17 +18,17 @@ async function setup(h) {
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
const items = [];
for (const v of [true, true, true, false]) items.push((await importItem(h, steamItem({ votedUp: v }))).item);
-- const sigs = h.db.prepare("SELECT id, recommended, source_item_id FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);
++ const sigs = await h.db.prepare("SELECT id, recommended, source_item_id FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);
return { e, items, sigs, pos: sigs.filter((s) => s.recommended === 1).map((s) => s.id), neg: sigs.filter((s) => s.recommended === 0).map((s) => s.id) };
}
--const aggRevision = (h, id) => (h.db.prepare('SELECT MAX(revision) AS r FROM review_aggregates WHERE entity_id = ?').get(id).r);
++const aggRevision = async (h, id) => ((await h.db.prepare('SELECT MAX(revision) AS r FROM review_aggregates WHERE entity_id = ?').get(id)).r);
t('AI text never yields a rating: rating fields and rating text are refused, the aggregate is untouched', async () => {
const h = await boot();
try {
const { e, pos } = await setup(h);
-- const rev = aggRevision(h, e.id);
++ const rev = await aggRevision(h, e.id);
const withField = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: AI(), body: { overview: 'Fine.', overview_signals: pos, rating: 4.5, workflow: WF } });
assert.strictEqual(withField.status, 422);
assert.strictEqual(withField.json.code, 'summary.rating_forbidden');
@@@ -39,7 +39,7 @@ t('AI text never yields a rating: rating fields and rating text are refused, the
assert.strictEqual(r.status, 422, text);
assert.strictEqual(r.json.code, 'summary.rating_in_text', text);
}
-- assert.strictEqual(aggRevision(h, e.id), rev);
++ assert.strictEqual(await aggRevision(h, e.id), rev);
// Even an entity with no signals gets no aggregate from AI text: summaries cannot cite what does not exist.
const empty = await createEntity(h, { name: 'Unknown Game', kind: 'game' });
const noSig = await req(h, 'POST', `/api/v1/entities/${empty.slug}/summary/proposals`, { token: AI(), body: { overview: 'Widely loved.', pros: [{ text: 'Loved', signals: pos }], workflow: WF } });
@@@ -73,8 +73,8 @@ t('an AI draft is held: not on the page, not publishable, not indexed, until a p
const pageBefore = await req(h, 'GET', `/e/${e.slug}`);
assert.match(pageBefore.text, /No published summary/);
assert.ok(!pageBefore.text.includes('Most sampled reviewers recommend it'));
-- assert.ok(!outbox(h).some((x) => x.event_type === 'reviews.summary.published'));
-- assert.ok(!outbox(h).some((x) => JSON.stringify(x.payload).includes('Most sampled reviewers')), 'the draft never reaches Search');
++ assert.ok(!(await outbox(h)).some((x) => x.event_type === 'reviews.summary.published'));
++ assert.ok(!(await outbox(h)).some((x) => JSON.stringify(x.payload).includes('Most sampled reviewers')), 'the draft never reaches Search');
const feed = await req(h, 'GET', '/feed.json');
assert.strictEqual(JSON.parse(feed.text).items.length, 0);
@@@ -90,7 +90,7 @@ t('an AI draft is held: not on the page, not publishable, not indexed, until a p
const page = await req(h, 'GET', `/e/${e.slug}`);
assert.match(page.text, /AI-generated by workflow reviews\.summarize_entity v1, reviewed by a person/);
assert.match(page.text, /Most sampled reviewers recommend it/);
-- const ev = outbox(h).filter((x) => x.event_type === 'reviews.summary.published');
++ const ev = (await outbox(h)).filter((x) => x.event_type === 'reviews.summary.published');
assert.strictEqual(ev.length, 1);
// The review JSON-LD carries no reviewRating: a summary never states a rating.
const ld = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
@@@ -122,7 +122,7 @@ t('human summaries cite signals for every point; a withdrawn cited signal flags
// The source removes the only negative review: the con loses its support.
const negItem = sigs.find((s) => s.recommended === 0).source_item_id;
await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(negItem, 'author deleted the review'), { reason: 'author deleted the review' }));
-- const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
++ const s = await h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
assert.strictEqual(s.flagged, 1);
assert.match(s.flag_reason, /withdrawn/);
const page = await req(h, 'GET', `/api/v1/entities/${e.slug}`, { token: editorToken() });
@@@ -141,10 +141,10 @@ t('human summaries cite signals for every point; a withdrawn cited signal flags
assert.strictEqual(direct.status, 409);
const ok = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions/${pending.number}/review`, { token: editorToken(), body: { decision: 'approved' } });
assert.strictEqual(ok.status, 200, ok.text);
-- const fresh = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
++ const fresh = await h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
assert.strictEqual(fresh.flagged, 0);
assert.strictEqual(fresh.published_revision, pending.number);
-- assert.ok(outbox(h).some((x) => x.event_type === 'reviews.summary.updated'));
++ assert.ok((await outbox(h)).some((x) => x.event_type === 'reviews.summary.updated'));
} finally { await h.stop(); }
});
ddiff --git a/test/threat.test.js b/test/threat.test.js
iindex 2ed4b09..b0de2da 100644
---- a/test/threat.test.js
++++ b/test/threat.test.js
@@@ -14,7 +14,7 @@ const {
const t = suite('threat');
const ORIGIN = 'http://reviews.test';
--const count = (h, name) => h.db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE name = ?').get(name).n;
++const count = async (h, name) => (await h.db.prepare('SELECT COUNT(*) AS n FROM review_entities WHERE name = ?').get(name)).n;
t('cross-site writes are refused on pages and on the cookie-authenticated API; same-origin and Bearer writes are not', async () => {
const h = await boot();
@@@ -26,7 +26,7 @@ t('cross-site writes are refused on pages and on the cookie-authenticated API; s
assert.strictEqual((await post({ Origin: 'null', 'Sec-Fetch-Site': 'cross-site' }, 'null origin from another site')).status, 403);
assert.strictEqual((await post({ 'Sec-Fetch-Site': 'same-site' }, 'a sibling subdomain posting')).status, 403);
assert.strictEqual((await post({ Origin: 'https://evil.example', 'Sec-Fetch-Site': 'same-origin' }, 'a foreign origin whatever else')).status, 403);
-- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM review_corrections').get().n, 0);
++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM review_corrections').get()).n, 0);
assert.strictEqual((await post({ Origin: ORIGIN, 'Sec-Fetch-Site': 'same-origin' }, 'a same-origin form post')).status, 201);
assert.strictEqual((await post({ Origin: 'null' }, 'privacy settings, no fetch metadata')).status, 201);
@@@ -37,7 +37,7 @@ t('cross-site writes are refused on pages and on the cookie-authenticated API; s
assert.strictEqual(r.json.code, 'request.cross_site');
r = await api('Via cookie, cross-site fetch', { 'Sec-Fetch-Site': 'cross-site' }, { cookie: ed });
assert.strictEqual(r.status, 403, r.text);
-- assert.strictEqual(count(h, 'Via cookie from evil') + count(h, 'Via cookie, cross-site fetch'), 0);
++ assert.strictEqual(await count(h, 'Via cookie from evil') + await count(h, 'Via cookie, cross-site fetch'), 0);
assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/unpublish`, { cookie: ed, headers: { 'Sec-Fetch-Site': 'cross-site' } })).status, 403, 'an empty-body write too');
assert.strictEqual((await api('Via cookie, same origin', { Origin: ORIGIN, 'Sec-Fetch-Site': 'same-origin' }, { cookie: ed })).status, 201);
assert.strictEqual((await api('Via Bearer', { Origin: 'https://tool.example' }, { token: editorToken() })).status, 201, 'a Bearer token is never ambient');
@@@ -59,7 +59,7 @@ t('the correction queue: one open copy of a request, a daily allowance per perso
const huge = await send('A very long target id follows.', { target_id: 'x'.repeat(100000) });
assert.strictEqual(huge.status, 201);
assert.strictEqual(huge.json.correction.target_id.length, 100);
-- const logged = h.db.prepare("SELECT detail FROM review_audit WHERE action = 'correction.submitted' ORDER BY id DESC").get();
++ const logged = await h.db.prepare("SELECT detail FROM review_audit WHERE action = 'correction.submitted' ORDER BY id DESC").get();
assert.ok(logged.detail.length < 300, 'the audit row holds the capped id, not the request');
for (let i = 3; i <= 20; i++) assert.strictEqual((await send(`Correction number ${i} about this entity.`)).status, 201, `#${i}`);
const over = await send('One more than the daily allowance.');
@@@ -81,12 +81,12 @@ t('audit text is bounded; readers see "an editor", never an editor\'s id; the en
const b = await createEntity(h, { name: 'Widget (old listing)', kind: 'product' });
const m = await req(h, 'POST', `/api/v1/entities/${b.slug}/merge`, { token: editorToken(), body: { into: a.slug, note: 'n'.repeat(50000) } });
assert.strictEqual(m.status, 200, m.text);
-- const row = h.db.prepare("SELECT detail FROM review_audit WHERE action = 'entity.merged'").get();
++ const row = await h.db.prepare("SELECT detail FROM review_audit WHERE action = 'entity.merged'").get();
assert.ok(row.detail.length < 3000, `audit detail is ${row.detail.length} characters`);
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
for (const v of [true, false]) await importItem(h, steamItem({ votedUp: v }));
-- const sigs = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all().map((s) => s.id);
++ const sigs = (await h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all()).map((s) => s.id);
assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { overview: 'Opinion in the sample is split.', overview_signals: sigs, publish: true } })).status, 201);
for (const path of [`/e/${e.slug}.json`, `/api/v1/entities/${e.slug}`, `/api/v1/entities/${e.slug}/history`, `/api/v1/entities/${e.slug}/summary/revisions/1`, `/e/${e.slug}`, `/e/${e.slug}/history`]) {
const r = await req(h, 'GET', path);
@@@ -110,7 +110,7 @@ t('AI text cannot spell a rating out in words; out-of-scale source ratings are n
try {
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
for (const v of [true, true]) await importItem(h, steamItem({ votedUp: v }));
-- const sigs = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all().map((s) => s.id);
++ const sigs = (await h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all()).map((s) => s.id);
const AI = serviceToken({ client: 'ai', cap: ['reviews.summary.propose'] });
const wf = { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ' };
for (const text of ['Easily four and a half stars.', 'A nine out of ten experience.', 'A five-star puzzle game.']) {