openvibers/openvibe.reviews Commits
Reviews moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.
Changes
@@@ -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@@@ -9,6 +9,9 @@ jobs:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with: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@@@ -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 theOpenVibe.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@@@ -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",@@@ -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.serviceWants=openvibe-network.service openvibe-sources.service[Service]nnew file mode 100644@@@ -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;@@@ -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"++ }}}}@@@ -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"}}nnew file mode 100644@@@ -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 };@@@ -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 = consorequire('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 = consoconst 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 = consoapp.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' }));@@@ -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;}@@@ -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).@@@ -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--);--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)--);--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);--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 };@@@ -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, lolimits: { 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}`);@@@ -66,70 +66,70 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limitsconst 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, limitsif (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, limitsreturn { 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;@@@ -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 });@@@ -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) => {@@@ -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, limiconst 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, limirouter.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');}));@@@ -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.feconst 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.fefor (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 };}@@@ -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 = nulonWarning: (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;@@@ -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.nowinsertEntity: 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.nowrevision_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 itconst 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.nowif (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.nowif (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.nowreturn 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.nowcanonicalUrl: 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.nowdecision,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.nowif (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.nowobserved_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.nowfor (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.nowreturn 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.nowlast_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.nowstate: '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.nowreturn 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.nowENTITY_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.nowgateFacts,/** 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.nowasync 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.nowitem_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.nowif (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.nowif (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'),};},};@@@ -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; },@@@ -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 itclock = 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}`);@@@ -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 showsconst 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 showsassert.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 showsassert.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 },} });nnew file mode 100644@@@ -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 };@@@ -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', asyconst 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(); }@@@ -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, dbPatif (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 = {}) {@@@ -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 exactconst 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 exactassert.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 exactassert.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 exactassert.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 baconst 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() });@@@ -70,7 +70,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots followassert.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 followconst 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);@@@ -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');@@@ -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.@@@ -29,7 +29,7 @@ t('items without an entity wait unmatched; a source binding settles them; everyassert.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; everyassert.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 rowconst 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 revisionassert.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 revisionagg = 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 changeassert.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'));@@@ -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, theassert.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 pconst 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 pconst 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 flagsassert.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(); }});@@@ -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; sassert.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; sassert.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 persoconst 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 enconst 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 ntry {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.']) {
@@ -19,6 +19,14 @@ NODE_ENV=development
@@ -19,6 +19,14 @@ NODE_ENV=development
#REVIEWS_LIMITS_HOUR=3000
#REVIEWS_LIMITS_HOUR=3000
# Store (SQLite, WAL). Production: /var/lib/openvibe-reviews/reviews.db (systemd StateDirectory).
# 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
REVIEWS_DB_PATH=./data/reviews.db
# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched
# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched
@@ -9,6 +9,9 @@ jobs:
@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: reviews
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).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -18,7 +18,7 @@ it rests on, and nothing a summary says can become a number.
@@ -18,7 +18,7 @@ it rests on, and nothing a summary says can become a number.
## Owns
## 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 |
| Table | What it holds |
|---|---|
|---|---|
@@ -26,7 +26,7 @@ The nine authority tables of §15.13, in Reviews' own SQLite database:
@@ -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_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_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_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_summaries` | one editorial summary per entity, its publication state and review flag |
| `review_summary_revisions` | immutable summary revisions (openvibe-publishing/revisions) with authorship |
| `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 |
| `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.
@@ -225,7 +225,8 @@ by editors; signals arrive only from Sources.
- OpenVibe.Community — discussion threads (`community.comment.write`)
- OpenVibe.Community — discussion threads (`community.comment.write`)
- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
- OpenVibe.Search — consumes the index events
- 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
## Capabilities
@@ -285,13 +286,16 @@ references and the gaps that remain: [docs/threat-review.md](docs/threat-review.
@@ -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,
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`).
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
`/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
[deploy/nginx/openvibe.reviews.conf](deploy/nginx/openvibe.reviews.conf) (the domain still serves the
OpenVibe.Sites placeholder).
OpenVibe.Sites placeholder).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
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
## Launch rule
@@ -7,8 +7,12 @@
@@ -7,8 +7,12 @@
"runtime": "node 22, express, better-sqlite3",
"runtime": "node 22, express, better-sqlite3",
"port": 4830,
"port": 4830,
"domain": "openvibe.reviews",
"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": [
"capabilities": [
"reviews.entity.resolve",
"reviews.entity.resolve",
"reviews.entity.manage",
"reviews.entity.manage",
@@ -4,7 +4,7 @@ Description=OpenVibe.Reviews — review signals with provenance, entity resoluti
@@ -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.
# 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, 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).
# 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
Wants=openvibe-network.service openvibe-sources.service
[Service]
[Service]
new file mode 100644
new file mode 100644
@@ -0,0 +1,371 @@
@@ -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;
@@ -14,15 +14,33 @@
@@ -14,15 +14,33 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"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": {
"engines": {
"node": ">=22.0.0"
"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": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -224,6 +242,15 @@
@@ -224,6 +242,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"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": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -306,6 +333,15 @@
@@ -306,6 +333,15 @@
"node": ">=4.0.0"
"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": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -730,6 +766,49 @@
@@ -730,6 +766,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"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": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -794,12 +873,24 @@
@@ -794,12 +873,24 @@
"safe-buffer": "^5.0.1"
"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": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"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": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -999,9 +1090,9 @@
@@ -999,9 +1090,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1015,15 +1106,15 @@
@@ -1015,15 +1106,15 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
},
},
"peerDependencies": {
"peerDependencies": {
- "better-sqlite3": ">=11",
+ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
"openvibe-shared": ">=1.5.0"
},
},
"peerDependenciesMeta": {
"peerDependenciesMeta": {
@@ -1033,12 +1124,32 @@
@@ -1033,12 +1124,32 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"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": {
"node_modules/openvibe-shared": {
@@ -1065,6 +1176,134 @@
@@ -1065,6 +1176,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"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": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1188,6 +1427,27 @@
@@ -1188,6 +1427,27 @@
"node": ">= 6"
"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": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1403,6 +1663,21 @@
@@ -1403,6 +1663,21 @@
"simple-concat": "^1.0.0"
"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": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1530,6 +1805,15 @@
@@ -1530,6 +1805,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"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"
+ }
}
}
}
}
}
}
@@ -7,9 +7,14 @@
@@ -7,9 +7,14 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Reviews",
"repository": "github:OpenVibers/OpenVibe.Reviews",
@@ -19,12 +24,17 @@
@@ -19,12 +24,17 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"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": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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 };
@@ -25,6 +25,22 @@ const { createActorLimits } = require('./http/actor-limits');
@@ -25,6 +25,22 @@ const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
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 }) {
function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -33,22 +49,24 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
@@ -33,22 +49,24 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
require('openvibe-shared/trace').install(app);
require('openvibe-shared/trace').install(app);
const release = require('openvibe-shared/release').createRelease({ service: 'reviews', root: path.join(__dirname, '..') });
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 });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'reviews', release: release.release });
metrics.registry.gauge({
metrics.registry.gauge({
name: 'reviews_event_outbox', help: 'Events in the outbox by state', labelNames: ['state'],
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({
metrics.registry.gauge({
name: 'reviews_work', help: 'Editorial and import backlog', labelNames: ['kind'],
name: 'reviews_work', help: 'Editorial and import backlog', labelNames: ['kind'],
- collect: () => {
- const s = svc.stats();
+ collect: lagged(async () => {
+ const s = await svc.stats();
return [
return [
{ labels: { kind: 'items_unresolved' }, value: s.items_unresolved },
{ labels: { kind: 'items_unresolved' }, value: s.items_unresolved },
{ labels: { kind: 'corrections_open' }, value: s.corrections_open },
{ labels: { kind: 'corrections_open' }, value: s.corrections_open },
{ labels: { kind: 'summaries_flagged' }, value: s.summaries_flagged },
{ 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) => {
app.use((req, res, next) => {
@@ -87,7 +105,17 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
@@ -87,7 +105,17 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
const ready = createReadiness({
const ready = createReadiness({
service: 'reviews', release: release.release,
service: 'reviews', release: release.release,
checks: [
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: '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: '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') },
{ 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
@@ -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: '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') },
{ 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);
app.get('/api/ready', ready.handler);
@@ -107,7 +135,7 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
@@ -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));
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
// 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).
// 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/v1', createApi({ svc, viewers, platform, sync, config, log, limits }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
@@ -97,7 +97,7 @@ function createViewerResolver({ keys, config }) {
@@ -97,7 +97,7 @@ function createViewerResolver({ keys, config }) {
const payload = decodePayload(token);
const payload = decodePayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (opts.services === false) return ANONYMOUS;
if (opts.services === false) return ANONYMOUS;
- return fromServiceToken(req, token);
+ return await fromServiceToken(req, token);
}
}
return (await fromUserToken(token, { strict: true })) || ANONYMOUS;
return (await fromUserToken(token, { strict: true })) || ANONYMOUS;
}
}
@@ -31,6 +31,10 @@ function load(env = process.env) {
@@ -31,6 +31,10 @@ function load(env = process.env) {
minute: Math.max(1, int(env.REVIEWS_LIMITS_MINUTE, 120)),
minute: Math.max(1, int(env.REVIEWS_LIMITS_MINUTE, 120)),
hour: Math.max(1, int(env.REVIEWS_LIMITS_HOUR, 3000)),
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',
dbPath: env.REVIEWS_DB_PATH || './data/reviews.db',
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'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:
* The nine authority tables of roadmap §15.13:
* review_entities the things reviewed; merged entities point at their canonical entity
* review_entities the things reviewed; merged entities point at their canonical entity
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createReviewLog } = require('openvibe-publishing/authorship');
const { createReviewLog } = require('openvibe-publishing/authorship');
@@ -42,264 +42,30 @@ const TRUST_KEYS = ['aggregate', 'limitation', 'verification'];
@@ -42,264 +42,30 @@ const TRUST_KEYS = ['aggregate', 'limitation', 'verification'];
const q = (list) => list.map((s) => `'${s}'`).join(',');
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
-);
-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)
-);
-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);
-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() } = {}) {
function createStores(db, { now = () => Date.now() } = {}) {
- db.exec(SCHEMA);
const revisions = createRevisionStore(db, { prefix: 'review_summary', now });
const revisions = createRevisionStore(db, { prefix: 'review_summary', now });
return {
return {
revisions,
revisions,
@@ -315,4 +81,4 @@ const AUTHORITY_TABLES = Object.freeze([
@@ -315,4 +81,4 @@ const AUTHORITY_TABLES = Object.freeze([
'review_summaries', 'review_summary_revisions', 'review_trust_metadata', 'review_entity_links',
'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 };
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the pages people read,
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the pages people read,
* and the signed Events deliveries at /internal/events.
* 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:/;
const FIRST_PARTY = /^svc:/;
@@ -83,7 +83,7 @@ const BUDGETS = {
@@ -83,7 +83,7 @@ const BUDGETS = {
* enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the
* enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the
* per-address limits.
* 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
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'] })
? 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;
: null;
@@ -91,6 +91,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -91,6 +91,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.limits.minute, hour: config.limits.hour },
limits: { minute: config.limits.minute, hour: config.limits.hour },
actor: enabled ? actor : () => null,
actor: enabled ? actor : () => null,
now,
now,
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is a subject id, a principal or an address, never a token.
// 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}`);
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -66,70 +66,70 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limits
@@ -66,70 +66,70 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limits
const R = (fn, status) => run(fn, status, log);
const R = (fn, status) => run(fn, status, log);
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
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 === '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;
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();
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.
// 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 } };
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 };
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));
}, 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');
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');
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).
// 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');
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);
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) => {
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
@@ -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');
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 b = body(req);
const out = await svc.importItem(b.source_item_id || b.item_id, platform.sources, a);
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) => {
router.post('/sources/sync', guard('reviews.signal.import'), B('reviews.sources.sync'), R(async (req) => {
const a = req.actor;
const a = req.actor;
@@ -147,26 +147,26 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limits
@@ -147,26 +147,26 @@ function createApi({ svc, viewers, platform, sync, config, log = console, limits
return { pull: pulled, queue: drained };
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');
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);
const b = body(req);
// Accepting with a correction publishes a summary revision: a service needs that grant too.
// 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)) {
if (req.actor.kind === 'service' && b.status === 'accepted' && (b.correction_note || b.summary)) {
const c = checkCapability(req.actor.claims, 'reviews.summary.publish');
const c = checkCapability(req.actor.claims, 'reviews.summary.publish');
if (!c.allowed) throw new svc.ReviewsError(403, c.code, c.reason);
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;
return router;
@@ -15,32 +15,31 @@
@@ -15,32 +15,31 @@
*/
*/
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
-const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const CONSUMER = 'reviews-sources';
const CONSUMER = 'reviews-sources';
function consumerRouter({ db, svc, sync, config, log = console }) {
function consumerRouter({ db, svc, sync, config, log = console }) {
const router = express.Router();
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' };
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';
if (event.source !== 'sources') return 'ignored:source';
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
if (p.category !== 'reviews') return 'ignored:category';
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 (!/^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 'ignored:type';
});
});
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
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;
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 });
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);
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
@@ -54,7 +53,7 @@ function consumerRouter({ db, svc, sync, config, log = console }) {
@@ -54,7 +53,7 @@ function consumerRouter({ db, svc, sync, config, log = console }) {
}
}
let out;
let out;
try {
try {
- out = apply(event);
+ out = await apply(event);
} catch (e) {
} catch (e) {
log.error(`[Reviews] event ${event.event_id} (${event.event_type}) failed:`, e.message);
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 });
return http.sendProblem(res, 500, 'reviews.event_failed', { detail: 'processing failed; it will be retried', ctx: req.ov });
@@ -27,12 +27,12 @@ function createMachine({ svc, config }) {
@@ -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` }];
const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
cache(res).type('application/xml').send(seo.sitemapIndex(maps));
cache(res).type('application/xml').send(seo.sitemapIndex(maps));
@@ -42,15 +42,15 @@ function createMachine({ svc, config }) {
@@ -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() }) }));
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]);
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);
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');
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]);
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}`,
id: `tag:openvibe.reviews,2026:summary/${summary.id}/revision/${summary.published_revision}`,
url: `${svc.entityUrl(entity)}#summary`,
url: `${svc.entityUrl(entity)}#summary`,
title: `${entity.name}: summary`,
title: `${entity.name}: summary`,
@@ -63,13 +63,13 @@ function createMachine({ svc, config }) {
@@ -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.');
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));
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) => {
router.get('/llms.txt', (_req, res) => {
@@ -121,12 +121,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -121,12 +121,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
};
};
/** :slug → an active entity, or the right redirect / 410 / 404. */
/** :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 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('?')) : '';
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
if (!e) {
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 === 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; }
if (r && r.status === 410) { gone(req, res); return null; }
notFound(req, res);
notFound(req, res);
@@ -134,7 +134,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -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 === 'deleted') { gone(req, res); return null; }
if (e.state === 'merged') {
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}`);
res.set('Cache-Control', 'public, max-age=60').redirect(301, `${svc.entityPath(c)}${suffix}${query}`);
return null;
return null;
}
}
@@ -142,9 +142,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -142,9 +142,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}
}
// ── Public ───────────────────────────────────────────────
// ── 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: '/',
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.' }),
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
@@ -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.
// 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('/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('/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);
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;
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)
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 } });
.json({ ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } });
});
});
async function discussionFor(e, actor) {
async function discussionFor(e, actor) {
if (!platform.community.configured) return { state: 'unavailable', reason: 'OpenVibe.Community is not configured on this server' };
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' };
if (!threadId) return { state: 'none' };
try {
try {
const data = await platform.community.getThread(threadId, { subject: actor.subject });
const data = await platform.community.getThread(threadId, { subject: actor.subject });
@@ -178,9 +178,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -178,9 +178,9 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}
}
router.get('/e/:slug', wrap(async (req, res) => {
router.get('/e/:slug', wrap(async (req, res) => {
- const e = locate(req, res);
+ const e = await locate(req, res);
if (!e) return;
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 discussion = await discussionFor(e, req.actor);
const head = seo.metaTags({
const head = seo.metaTags({
decision: p.decision, title: `${e.name} · OpenVibe.Reviews`, siteName: 'OpenVibe.Reviews', type: 'website',
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
@@ -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;
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) => {
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;
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 (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
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) => {
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 (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
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 || {};
const b = req.body || {};
try {
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) {
} catch (err) {
if (!err.status || err.status >= 500) throw 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) => {
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 (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to comment.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to comment.');
const message = String((req.body || {}).message || '').trim();
const message = String((req.body || {}).message || '').trim();
@@ -243,11 +243,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -243,11 +243,12 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}));
}));
// ── Editors ──────────────────────────────────────────────
// ── Editors ──────────────────────────────────────────────
- router.get('/editor', (req, res) => {
+ router.get('/editor', async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
send(req, res, 200, views.editorHome({
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' });
}), { title: 'Editor desk', robots: 'noindex, nofollow' });
});
});
@@ -268,7 +269,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -268,7 +269,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const b = req.body || {};
const b = req.body || {};
const aliases = ['source', 'url', 'gtin', 'sku', 'external'].map((type) => ({ type, value: String(b[`alias_${type}`] || '').trim() })).filter((a) => a.value);
const aliases = ['source', 'url', 'gtin', 'sku', 'external'].map((type) => ({ type, value: String(b[`alias_${type}`] || '').trim() })).filter((a) => a.value);
try {
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`);
res.redirect(303, `${svc.entityPath(out.entity)}/edit`);
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500) throw err;
if (!err.status || err.status >= 500) throw err;
@@ -277,102 +278,102 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -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). */
/** 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;
if (!editorOnly(req, res)) return;
- const e = locate(req, res, '/edit');
+ const e = await locate(req, res, '/edit');
if (!e) return;
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) => {
router.post('/e/:slug/edit', F('reviews.entity.manage'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
- let e = locate(req, res, '/edit');
+ let e = await locate(req, res, '/edit');
if (!e) return;
if (!e) return;
const b = req.body || {};
const b = req.body || {};
try {
try {
switch (b.action) {
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': {
case 'trust': {
const [scope, ...rest] = String(b.scope_ref || '').split(':');
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;
break;
}
}
default: return errorPage(req, res, 400, 'Unknown action', 'The form sent an action this page does not know.');
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) {
} catch (err) {
if (!err.status || err.status >= 500) throw 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) => {
router.post('/e/:slug/summary', F('reviews.summary.publish'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
- const e = locate(req, res, '/summary');
+ const e = await locate(req, res, '/summary');
if (!e) return;
if (!e) return;
try {
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`);
res.redirect(303, `${svc.entityPath(e)}/edit?saved=1`);
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500) throw 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) => {
router.post('/e/:slug/summary/:n/review', F('reviews.summary.publish'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
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;
if (!e) return;
const b = req.body || {};
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`);
res.redirect(303, `${svc.entityPath(e)}?saved=1`);
}));
}));
router.post('/e/:slug/merge', F('reviews.entity.merge'), form, wrap(async (req, res) => {
router.post('/e/:slug/merge', F('reviews.entity.merge'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
- const e = locate(req, res, '/merge');
+ const e = await locate(req, res, '/merge');
if (!e) return;
if (!e) return;
const b = req.body || {};
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`);
res.redirect(303, `${svc.entityPath(out.into)}/history#merges`);
}));
}));
router.post('/e/:slug/split', F('reviews.entity.merge'), form, wrap(async (req, res) => {
router.post('/e/:slug/split', F('reviews.entity.merge'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
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);
if (!e) return notFound(req, res);
const b = req.body || {};
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`);
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;
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);
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);
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' });
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) => {
router.post('/editor/items/:id', F('reviews.item.resolve'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
const b = req.body || {};
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 {
else {
const target = String(b.entity_other || '').trim() || String(b.entity || '').trim();
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');
res.redirect(303, '/editor#items');
}));
}));
@@ -380,7 +381,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -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) => {
router.post('/editor/corrections/:id', F('reviews.correction.resolve'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
const b = req.body || {};
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');
res.redirect(303, '/editor#corrections');
}));
}));
@@ -17,9 +17,10 @@ const { createKeyStore } = require('./auth/keys');
@@ -17,9 +17,10 @@ const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
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();
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 stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const svc = createReviewsService({ db, stores, outbox: platform.outbox, config, 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
@@ -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 });
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).
// 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 = [];
const timers = [];
if (workers) {
if (workers) {
@@ -55,7 +56,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -55,7 +56,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
for (const t of timers) { clearInterval(t); clearTimeout(t); }
for (const t of timers) { clearInterval(t); clearTimeout(t); }
await platform.outbox.stop();
await platform.outbox.stop();
if (server) await new Promise((resolve) => server.close(resolve));
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 };
return { app, db, svc, sync, stores, platform, keys, viewers, server, config, stop };
}
}
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
*/
*/
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
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');
const { createDiscussionClient } = require('openvibe-publishing/discussion');
class IntegrationError extends Error {
class IntegrationError extends Error {
@@ -42,11 +42,12 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
@@ -42,11 +42,12 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
onWarning: (msg) => log.warn(`[Reviews] ${msg}`),
onWarning: (msg) => log.warn(`[Reviews] ${msg}`),
});
});
const events = createEventsClient(sdk, { source: 'reviews' });
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,
events, table: 'review_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,
onError: (err) => log.warn(`[Reviews] event relay: ${err && err.message}`),
onError: (err) => log.warn(`[Reviews] event relay: ${err && err.message}`),
});
});
- outbox.ensureSchema();
async function getJson(url, audience, { headers = {}, timeoutMs = 8000 } = {}) {
async function getJson(url, audience, { headers = {}, timeoutMs = 8000 } = {}) {
let res;
let res;
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
*
*
* Methods take an actor (server/reviews/access.js) and throw ReviewsError (status + stable code).
* 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 seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
@@ -66,11 +67,12 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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)
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)`),
VALUES (@id, @slug, @name, @kind, @description, @created_by, @now, @now)`),
mergedInto: db.prepare("SELECT id FROM review_entities WHERE merged_into = ? AND state = 'merged'"),
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'"),
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 = ?'),
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'),
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'"),
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
@@ -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`),
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 = ?"),
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 = ?'),
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'),
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 = ?'),
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'),
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
@@ -153,7 +155,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
// ── Small helpers ────────────────────────────────────────
// ── Small helpers ────────────────────────────────────────
const fail = (status, code, message, extra) => { throw new ReviewsError(status, code, message, extra); };
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 entityPath = (e) => `/e/${encodeURIComponent(e.slug)}`;
const entityUrl = (e) => seo.canonicalUrl(origin, entityPath(e));
const entityUrl = (e) => seo.canonicalUrl(origin, entityPath(e));
const parse = (s, d) => { try { return s == null ? d : JSON.parse(s); } catch { return d; } };
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
@@ -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)]));
if (v && typeof v === 'object' && depth < 4) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clipText(x, depth + 1)]));
return v;
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 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');
if (!e) fail(404, 'entity.not_found', 'No such entity');
return e;
return e;
}
}
/** The entity a merged entity now lives under (follows merged_into). */
/** 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();
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;
return e ? e.id : null;
}
}
/** The entity and every entity merged into it, transitively. */
/** The entity and every entity merged into it, transitively. */
- function closure(entityId) {
+ async function closure(entityId) {
const out = new Set([entityId]);
const out = new Set([entityId]);
const stack = [entityId];
const stack = [entityId];
while (stack.length) {
while (stack.length) {
const id = stack.pop();
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;
return out;
}
}
- function activeSignalsIn(ids) {
+ async function activeSignalsIn(ids) {
const out = [];
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)));
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 = [];
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)));
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 ───────────────────────────────────────────
// ── Aggregates ───────────────────────────────────────────
- function exclusions() {
+ async function exclusions() {
return {
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
@@ -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
* 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.
* 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;
if (!e || e.state !== 'active') return null;
- const result = computeFor(canon);
+ const result = await computeFor(canon);
const hash = hashOf(result);
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 && last.inputs_hash === hash) return last;
if (!last && result === null) return null;
if (!last && result === null) return null;
const revision = last ? last.revision + 1 : 1;
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) {
function aggregateView(row) {
@@ -275,20 +277,20 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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. */
/** 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));
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 { 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) };
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) {
function cleanPoints(list, what) {
@@ -311,7 +313,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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. */
/** 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 }');
if (!input || typeof input !== 'object') fail(422, 'summary.invalid', 'A summary is { overview, pros, cons }');
for (const k of Object.keys(input)) {
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`);
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
@@ -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');
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)]) {
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`);
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 || !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`);
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 } };
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) {
if (!s) {
const t = now();
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;
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) {
function expectedOf(input, head) {
@@ -359,9 +361,9 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -359,9 +361,9 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return head;
return head;
}
}
- function createRevision(args) {
+ async function createRevision(args) {
try {
try {
- return revisions.create(args).revision;
+ return (await revisions.create(args)).revision;
} catch (err) {
} catch (err) {
if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
throw err;
throw err;
@@ -404,41 +406,41 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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
* 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.
* 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 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');
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;
let request = null;
if (requestId != null && String(requestId).trim() !== '') {
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 (!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}`);
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: {
meta: {
authorship: correctionAuthorship(pub.meta && pub.meta.authorship, who),
authorship: correctionAuthorship(pub.meta && pub.meta.authorship, who),
correction: { note: text, request: request ? request.id : null, corrects: pub.number },
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,
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) {
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;
const rec = rev.meta && rev.meta.authorship;
if (summary.state === 'published' && summary.published_revision === rev.number) return 'published';
if (summary.state === 'published' && summary.published_revision === rev.number) return 'published';
if (review && review.decision === 'rejected') return 'rejected';
if (review && review.decision === 'rejected') return 'rejected';
@@ -452,36 +454,36 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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
* 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.
* 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;
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;
if (review && review.decision === 'rejected') return false;
const rec = rev.meta && rev.meta.authorship;
const rec = rev.meta && rev.meta.authorship;
return !(rec && !authorship.canPublish(rec, review).ok);
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 = [];
const out = [];
for (let n = (summary.published_revision || 0) + 1; n <= head; n++) {
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 });
if (st !== 'rejected') out.push({ rev, status: st });
}
}
return out;
return out;
}
}
// ── The gate, the Search document ────────────────────────
// ── 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 };
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;
const rec = rev && rev.meta && rev.meta.authorship;
return {
return {
state: e.state === 'deleted' ? 'deleted' : 'published',
state: e.state === 'deleted' ? 'deleted' : 'published',
@@ -489,29 +491,29 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -489,29 +491,29 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
canonicalUrl: entityUrl(e),
canonicalUrl: entityUrl(e),
wordCount: rev ? ssr.wordCount(summaryText(rev)) : 0,
wordCount: rev ? ssr.wordCount(summaryText(rev)) : 0,
citationCount: active.length,
citationCount: active.length,
- unsupportedClaims: rev ? unsupportedCount(e.id, rev) : 0,
+ unsupportedClaims: rev ? await unsupportedCount(e.id, rev) : 0,
noindex: !!e.noindex,
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. */
/** 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;
if (!e) return null;
const live = e.state === 'active';
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 = [];
const lines = [];
if (agg && agg.result) lines.push(...agg.result.computation);
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,
owner: 'reviews', type: 'entity', id: e.id, revision: 0,
state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',
state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',
deleted: !live || !decision || !decision.listable,
deleted: !live || !decision || !decision.listable,
@@ -524,52 +526,48 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -524,52 +526,48 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
decision,
decision,
publishedAt: e.created_at, updatedAt: e.updated_at,
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;
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
// 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
// entity is recomputed once at the end: one editorial action or one Sources page → at most one
// new aggregate revision per entity.
// 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. */
/** 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 (!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 ───────────────────────────────────────────────
// ── Events ───────────────────────────────────────────────
- function signalPayload(s) {
+ async function signalPayload(s) {
const p = {
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,
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,
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
@@ -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; }
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;
return p;
}
}
- function signalEvent(type, s, actor, extra = {}) {
- emit({
+ async function signalEvent(type, s, actor, extra = {}) {
+ await emit({
event_type: type,
event_type: type,
actor: hooks.subjectRef(actorId(actor)),
actor: hooks.subjectRef(actorId(actor)),
subject: { type: 'signal', id: s.id },
subject: { type: 'signal', id: s.id },
visibility: 'public',
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 ───────────────
// ── Summary flags when cited signals leave ───────────────
@@ -599,71 +597,71 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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
* 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.
* 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;
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;
if (!e || e.state !== 'active') return null;
- const headN = revisions.headNumber(s.id);
+ const headN = await revisions.headNumber(s.id);
if (!headN) return null;
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)
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;
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();
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;
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 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 dropped = [...pros, ...cons].filter((p) => !p.signals.length).map((p) => p.text);
const t = now();
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,
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 },
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 } },
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,
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;
return revision;
}
}
- function flagSummariesCiting(signalIds, reason) {
+ async function flagSummariesCiting(signalIds, reason) {
const ids = new Set();
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 ──────────────────────────────────────────────
// ── 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;
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;
return true;
}
}
/** A new signal for an item revision; the item's previous active signal is superseded. */
/** 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 };
if (prev && prev.item_revision === itemRow.item_revision && prev.entity_id === entityId) return { signal: prev, created: false };
const t = now();
const t = now();
const id = `sig_${ulid(t)}`;
const id = `sig_${ulid(t)}`;
if (prev) {
if (prev) {
const why = prev.entity_id !== entityId ? 'reattributed by an editor' : `source item revised (r${prev.item_revision} → r${itemRow.item_revision})`;
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,
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,
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,
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
@@ -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,
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,
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) {
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 };
return { signal, created: true, replaced: prev ? prev.id : null };
}
}
@@ -690,15 +688,15 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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).
* give candidates, and a name is never enough on its own → ambiguous (an editor confirms).
* Nothing → unmatched (an editor creates or picks the entity).
* Nothing → unmatched (an editor creates or picks the entity).
*/
*/
- function resolveIdentifiers({ strong = [], names = [] }) {
+ async function resolveIdentifiers({ strong = [], names = [] }) {
const hits = [];
const hits = [];
for (const id of strong) {
for (const id of strong) {
const n = norm.tryAlias(id.type, id.value);
const n = norm.tryAlias(id.type, id.value);
if (!n) continue;
if (!n) continue;
- const a = q.strongAlias.get(id.type, n);
+ const a = await q.strongAlias.get(id.type, n);
if (a) {
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))];
const canon = [...new Set(hits.map((h) => h.canonical))];
@@ -708,9 +706,9 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -708,9 +706,9 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
for (const name of names) {
for (const name of names) {
const n = norm.tryAlias('name', name);
const n = norm.tryAlias('name', name);
if (!n) continue;
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' });
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
@@ -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, {}) });
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. */
/** 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 (row.state !== 'active' || row.resolution !== 'resolved') {
if (active) {
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 };
return { signal: null };
}
}
const { signal } = extract.extractSignal({ kind: row.kind, fields: parse(row.fields, {}) });
const { signal } = extract.extractSignal({ kind: row.kind, fields: parse(row.fields, {}) });
if (!signal) {
if (!signal) {
if (active) {
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 { 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 t = now();
- const existing = q.source.get(key);
+ const existing = await q.source.get(key);
const values = {
const values = {
key, name: info && info.name ? String(info.name).slice(0, 300) : null,
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,
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
@@ -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,
last_success_at: info && info.last_success_at ? String(info.last_success_at) : null,
now: t,
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) {
function checkItem(item) {
@@ -784,21 +782,21 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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).
* nothing but the last-seen time. Synchronous (runs inside the caller's transaction).
* → { outcome, item, signal }
* → { 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);
checkItem(item);
if (item.category && item.category !== 'reviews') return { outcome: 'ignored:category', item: null, signal: null };
if (item.category && item.category !== 'reviews') return { outcome: 'ignored:category', item: null, signal: null };
const prov = item.provenance;
const prov = item.provenance;
const t = now();
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();
const retrievedAt = new Date(prov.retrieved_at).toISOString();
if (item.removed) {
if (item.removed) {
const reason = String((item.removed && item.removed.reason) || 'removed').slice(0, 500);
const reason = String((item.removed && item.removed.reason) || 'removed').slice(0, 500);
if (!row) {
if (!row) {
// Known only as removed: kept for the record, never a signal.
// Known only as removed: kept for the record, never a signal.
const fields = extract.keptFields(item);
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),
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,
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,
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
@@ -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,
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,
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 };
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 };
return { outcome: 'removed', item: row, signal: null };
}
}
if (row && row.state === 'removed') return { outcome: 'ignored: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) return { outcome: 'ignored:older_revision', item: row, signal: null };
if (row && item.revision === row.item_revision && row.content_hash === prov.content_hash) {
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 fields = extract.keptFields(item);
const { note } = extract.extractSignal(item);
const { note } = extract.extractSignal(item);
@@ -830,38 +828,38 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -830,38 +828,38 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
};
};
let outcome;
let outcome;
if (!row) {
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';
outcome = 'created';
} else {
} else {
- q.updateItemContent.run(values);
+ await q.updateItemContent.run(values);
outcome = 'updated';
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. */
/** 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;
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;
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;
return settled;
});
});
}
}
// ── Entities ─────────────────────────────────────────────
// ── Entities ─────────────────────────────────────────────
- function uniqueSlug(base) {
+ async function uniqueSlug(base) {
const root = norm.slug(base) || 'entity';
const root = norm.slug(base) || 'entity';
let s = root;
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;
return s;
}
}
@@ -873,46 +871,46 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -873,46 +871,46 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return t;
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(', ')}`);
if (!ALIAS_TYPES.includes(type)) fail(422, 'alias.invalid', `alias type is one of ${ALIAS_TYPES.join(', ')}`);
let n;
let n;
try { n = norm.alias(type, value); } catch (err) { fail(422, 'alias.invalid', err.message); }
try { n = norm.alias(type, value); } catch (err) { fail(422, 'alias.invalid', err.message); }
if (type !== 'name') {
if (type !== 'name') {
- const taken = q.strongAlias.get(type, n);
+ const taken = await q.strongAlias.get(type, n);
if (taken) {
if (taken) {
if (taken.entity_id === entityId) return 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 });
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 t = now();
const id = `als_${ulid(t)}`;
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 {
return {
id: e.id, slug: e.slug, name: e.name, kind: e.kind, description: e.description, state: e.state,
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),
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 = {};
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;
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 {
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: {
provenance: {
source_key: s.source_key, source_name: src ? src.name : null, source_homepage: src ? src.homepage_url : null,
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,
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
@@ -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. */
/** `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;
if (!rev) return null;
const rec = rev.meta && rev.meta.authorship;
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 {
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,
authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, stub_provider: !!rec.stubProvider } : null,
disclosure: rec ? authorship.disclosure(rec, review) : null,
disclosure: rec ? authorship.disclosure(rec, review) : null,
review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,
review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,
@@ -947,12 +945,14 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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). */
/** The summary's history as readers may see it, newest first (entity page). */
- function publicHistory(summary) {
+ async function publicHistory(summary) {
if (!summary) return [];
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.
// 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
@@ -970,19 +970,19 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
ENTITY_KINDS, ALIAS_TYPES, LINK_TYPES, TRUST_KEYS, SUMMARY_WORKFLOW,
ENTITY_KINDS, ALIAS_TYPES, LINK_TYPES, TRUST_KEYS, SUMMARY_WORKFLOW,
// Lookups ----------------------------------------------------------------------------
// 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);
const s = String(text || '').trim().slice(0, 200);
if (!s) return [];
if (!s) return [];
const esc = (x) => x.replace(/[\\%_]/g, (c) => `\\${c}`);
const esc = (x) => x.replace(/[\\%_]/g, (c) => `\\${c}`);
const n = norm.tryAlias('name', s) || s.toLowerCase();
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,
entityView,
signalView,
signalView,
@@ -991,215 +991,215 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -991,215 +991,215 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
gateFacts,
gateFacts,
/** Everything the entity page shows. `e` must be active (callers redirect merged entities). */
/** 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 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 sourceKeys = [...new Set(signals.map((s) => s.source_key))].sort();
const editor = access.isEditor(actor);
const editor = access.isEditor(actor);
return {
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,
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),
direction: l.from_entity === e.id ? 'out' : 'in', created_at: toIso(l.created_at),
- })),
+ })))),
aggregate: agg && agg.result ? agg : null,
aggregate: agg && agg.result ? agg : null,
aggregate_revision: agg ? agg.revision : 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 ? {
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_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,
} : 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,
editor,
};
};
},
},
- history(e, actor) {
+ async history(e, actor) {
const editor = access.isEditor(actor);
const editor = access.isEditor(actor);
if (e.state === 'deleted' && !editor) fail(410, 'entity.deleted', 'This entity was deleted');
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 {
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,
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,
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),
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');
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');
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}`);
if (!rev) fail(404, 'revision.not_found', `No revision ${n}`);
const editor = access.isEditor(actor);
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) ---------------------------------------------------
// Resolve (reviews.entity.resolve) ---------------------------------------------------
/** { name?, url?, sku?, gtin?, mpn?, source?, external? } → { match, entity, via, rule, candidates } */
/** { name?, url?, sku?, gtin?, mpn?, source?, external? } → { match, entity, via, rule, candidates } */
- resolve(input = {}) {
+ async resolve(input = {}) {
const strong = [];
const strong = [];
for (const t of ['source', 'external', 'url', 'gtin', 'sku', 'mpn']) if (input[t]) strong.push({ type: t, value: String(input[t]) });
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)] : [];
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');
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 {
return {
match: r.resolution === 'resolved' ? 'exact' : r.resolution === 'ambiguous' ? 'ambiguous' : 'none',
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,
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) -----------------------------------------------------------------
// 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');
requireEditor(actor, 'Creating an entity');
const clean = checkName(name);
const clean = checkName(name);
if (!ENTITY_KINDS.includes(kind)) fail(422, 'entity.invalid_kind', `kind is one of ${ENTITY_KINDS.join(', ')}`);
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 }');
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 t = now();
const id = `ent_${ulid(t)}`;
const id = `ent_${ulid(t)}`;
const s = slug ? norm.slug(slug) : null;
const s = slug ? norm.slug(slug) : null;
if (slug && !s) fail(422, 'entity.invalid_slug', 'That slug has no letters or digits');
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');
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}`);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
const next = { ...e };
const next = { ...e };
const changes = {};
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 (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 (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 (noindex != null && !!noindex !== !!e.noindex) { next.noindex = noindex ? 1 : 0; changes.noindex = !!noindex; }
if (slug != null && slug !== e.slug) {
if (slug != null && slug !== e.slug) {
const s = norm.slug(slug);
const s = norm.slug(slug);
if (!s) fail(422, 'entity.invalid_slug', 'That slug has no letters or digits');
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`);
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];
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);
.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');
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 (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');
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');
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');
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 };
return { alias: a, settled_items: settled };
});
});
},
},
- removeAlias(ref, aliasId, actor) {
+ async removeAlias(ref, aliasId, actor) {
requireEditorPerson(actor, 'Removing an alias');
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');
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');
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)`);
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 target = null;
let refJson = null;
let refJson = null;
if (type === 'external_ref') {
if (type === 'external_ref') {
const { entityRef: mk } = require('openvibe-publishing/discussion');
const { entityRef: mk } = require('openvibe-publishing/discussion');
try { refJson = JSON.stringify(mk(entityRef || {})); } catch (err) { fail(422, 'link.invalid', err.message); }
try { refJson = JSON.stringify(mk(entityRef || {})); } catch (err) { fail(422, 'link.invalid', err.message); }
} else {
} else {
- target = entityOrFail(to);
+ target = await entityOrFail(to);
if (target.id === e.id) fail(422, 'link.invalid', 'An entity cannot link to itself');
if (target.id === e.id) fail(422, 'link.invalid', 'An entity cannot link to itself');
}
}
const t = now();
const t = now();
const id = `lnk_${ulid(t)}`;
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');
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)');
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
@@ -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
* link, and the target's aggregate is recomputed over both. That is why a split restores
* the pre-merge attribution exactly.
* the pre-merge attribution exactly.
*/
*/
- merge(ref, { into, note = null } = {}, actor) {
+ async merge(ref, { into, note = null } = {}, actor) {
const who = requireEditorPerson(actor, 'Merging entities');
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.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 (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 t = now();
const linkId = `lnk_${ulid(t)}`;
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,
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,
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,
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');
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`);
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');
if (!link) fail(409, 'split.no_merge_link', 'No active merge link for this entity');
const t = now();
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.
// 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,
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
@@ -1280,26 +1280,26 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
async importItem(itemId, sources, actor) {
async importItem(itemId, sources, actor) {
const { item, source } = await sources.getItem(String(itemId || ''));
const { item, source } = await sources.getItem(String(itemId || ''));
let info = source || null;
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 */ }
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). */
/** 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) return { outcome: 'ignored:unknown_item' };
if (row.state === 'removed') return { outcome: 'unchanged' };
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;
if (!row) return null;
const fields = parse(row.fields, {});
const fields = parse(row.fields, {});
return {
return {
@@ -1307,50 +1307,50 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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,
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,
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,
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). */
/** 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');
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) fail(404, 'item.not_found', 'Reviews has not read that item');
if (row.state !== 'active') fail(409, 'item.removed', 'Its source removed this 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}`);
if (e.state !== 'active') fail(409, 'entity.not_active', `"${e.name}" is ${e.state}`);
const before = { resolution: row.resolution, entity_id: row.entity_id };
const before = { resolution: row.resolution, entity_id: row.entity_id };
if (addAlias) {
if (addAlias) {
const ids = itemIdentifiers(row);
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);
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`);
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');
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');
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 --------------------------------------------------------------------
// 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');
requireEditorPerson(actor, 'Trust decisions');
if (!['source', 'signal', 'entity'].includes(scope)) fail(422, 'trust.invalid', 'scope is source, signal or entity');
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(', ')}`);
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
@@ -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' && !['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' && 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');
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 = [];
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);
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,
trustMap,
// Summaries (reviews.summary.publish | reviews.summary.propose) ----------------------
// Summaries (reviews.summary.publish | reviews.summary.propose) ----------------------
@@ -1382,24 +1382,24 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -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
* 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.
* 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');
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 (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,
meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,
message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,
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;
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
@@ -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
* approves it, and it can never state a rating: its text is checked, and summaries have no
* field that could carry one.
* 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');
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 wf = input.workflow || {};
const runId = wf.run_id || wf.runId;
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)`);
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;
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); }
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}`);
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,
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,
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. */
/** 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');
const who = requireEditorPerson(actor, 'Reviewing a summary');
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
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');
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}`);
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;
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');
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}`);
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');
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}`);
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');
if (review && review.decision === 'rejected') fail(409, 'summary.rejected', 'This revision was rejected by an editor');
const rec = rev.meta && rev.meta.authorship;
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 (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');
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) });
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 wasPublished = summary.state === 'published';
const before = summary.published_revision;
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);
const action = !wasPublished ? 'published' : (before !== n ? 'updated' : null);
if (action) {
if (action) {
- emit(hooks.publicationEvent({
+ await emit(hooks.publicationEvent({
product: 'reviews', type: 'summary', action, id: summary.id, revision: n,
product: 'reviews', type: 'summary', action, id: summary.id, revision: n,
actor: hooks.subjectRef(actorId(actor)), decision, now: now(),
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) },
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 } } : {}) },
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');
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');
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,
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 },
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: true },
extra: { entity_id: e.id, entity_slug: fresh.slug },
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')
.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 })) }));
.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. */
/** 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;
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). */
/** 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). */
/** 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))
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'))
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) --------------------------------------------
// 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 (!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');
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();
const text = String(body == null ? '' : body).trim();
@@ -1550,62 +1550,62 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1550,62 +1550,62 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
if (evidenceUrl) {
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'); }
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');
if (e.state === 'deleted') fail(410, 'entity.deleted', 'This entity was deleted');
const t = now();
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.
// 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 id = `cor_${ulid(t)}`;
const target = targetId ? String(targetId).slice(0, 100) : null;
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') };
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
* 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
* 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
* with the public `correction_note` (and the new text in `summary`, or the published text
* carried forward). Rejecting creates nothing.
* 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');
const who = requireEditorPerson(actor, 'Resolving a correction');
if (!['accepted', 'rejected'].includes(status)) fail(422, 'correction.invalid', 'status is accepted or rejected');
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 }');
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) fail(404, 'correction.not_found', 'No such correction');
if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);
if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);
const internal = note ? String(note).slice(0, 2000) : null;
const internal = note ? String(note).slice(0, 2000) : null;
if (status === 'accepted') {
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') {
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 };
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) -------------------------------------------------
// 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) {
async discussionThread(e, client) {
- const known = discussions.get(e.id);
+ const known = await discussions.get(e.id);
if (known) return known.threadId;
if (known) return known.threadId;
if (e.state !== 'active') return null;
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 });
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
@@ -1613,27 +1613,27 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
},
},
// Search reconciliation ---------------------------------------------------------------
// Search reconciliation ---------------------------------------------------------------
- reconcileIndex() {
- return tx(() => {
+ async reconcileIndex() {
+ return await tx(async () => {
let sent = 0;
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++;
if (doc && doc.revision !== before) sent++;
}
}
return { 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 {
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'),
};
};
},
},
};
};
@@ -27,8 +27,8 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
@@ -27,8 +27,8 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
pending: db.prepare('SELECT COUNT(*) AS n FROM review_import_queue'),
pending: db.prepare('SELECT COUNT(*) AS n FROM review_import_queue'),
};
};
const system = { kind: 'system', service: 'svc:reviews' };
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 pulling = null;
let draining = null;
let draining = null;
@@ -36,7 +36,7 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
@@ -36,7 +36,7 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
async function pullOnce({ maxPages = 25 } = {}) {
async function pullOnce({ maxPages = 25 } = {}) {
if (!platform.sources.configured) return { ok: false, reason: 'OpenVibe.Sources is not configured' };
if (!platform.sources.configured) return { ok: false, reason: 'OpenVibe.Sources is not configured' };
const counts = { pages: 0, applied: 0, unchanged: 0, skipped: 0 };
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 known = new Set();
const infos = new Map();
const infos = new Map();
try {
try {
@@ -46,27 +46,27 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
@@ -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))) {
for (const key of new Set(body.items.map((i) => i.source_key))) {
if (known.has(key)) continue;
if (known.has(key)) continue;
known.add(key);
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 */ }
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) {
for (const item of body.items) {
const info = { ...(infos.get(item.source_key) || {}), ...((body.sources && body.sources[item.source_key]) || {}) };
const info = { ...(infos.get(item.source_key) || {}), ...((body.sources && body.sources[item.source_key]) || {}) };
try {
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++;
if (out.outcome === 'unchanged') counts.unchanged++; else counts.applied++;
} catch (err) {
} 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 (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;
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 };
return { ok: true, after, ...counts };
} catch (err) {
} 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 };
return { ok: false, reason: err && err.message, after, ...counts };
}
}
}
}
@@ -75,15 +75,15 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
@@ -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 };
if (!platform.sources.configured) return { ok: false, reason: 'OpenVibe.Sources is not configured', done: 0 };
let done = 0;
let done = 0;
let failed = 0;
let failed = 0;
- for (const row of q.due.all(now(), limit)) {
+ for (const row of await q.due.all(now(), limit)) {
try {
try {
await svc.importItem(row.item_id, platform.sources, system);
await svc.importItem(row.item_id, platform.sources, system);
- q.done.run(row.item_id);
+ await q.done.run(row.item_id);
done++;
done++;
} catch (err) {
} catch (err) {
// A 404/422 is final (the item is gone or unusable); anything else is retried.
// 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++;
failed++;
}
}
}
}
@@ -91,9 +91,9 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
@@ -91,9 +91,9 @@ function createSourcesSync({ db, svc, platform, config, now = () => Date.now(),
}
}
return {
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,
state,
pull(opts) { if (!pulling) pulling = pullOnce(opts).finally(() => { pulling = null; }); return pulling; },
pull(opts) { if (!pulling) pulling = pullOnce(opts).finally(() => { pulling = null; }); return pulling; },
drain(opts) { if (!draining) draining = drainQueue(opts).finally(() => { draining = null; }); return draining; },
drain(opts) { if (!draining) draining = drainQueue(opts).finally(() => { draining = null; }); return draining; },
@@ -53,15 +53,15 @@ const H = require('./helpers');
@@ -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
// 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);
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++) {
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' } });
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}`);
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' } });
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.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' } });
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}`);
assert.strictEqual(r.status, 201, `another editor still writes: ${r.text}`);
@@ -15,7 +15,7 @@ const {
@@ -15,7 +15,7 @@ const {
const t = suite('corrections');
const t = suite('corrections');
const noScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
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 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 OLD = 'Every sampled Steam reviewer recommends it without reservation.';
const NEW = 'Three of the four sampled Steam reviewers recommend it; one does not.';
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';
@@ -26,7 +26,7 @@ const INTERNAL = 'internal: checked the signal table, reader is right';
async function setup(h) {
async function setup(h) {
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
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 }));
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 pos = sigs.filter((s) => s.recommended === 1).map((s) => s.id);
const neg = sigs.filter((s) => s.recommended === 0).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: {
const w = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
@@ -40,7 +40,7 @@ async function setup(h) {
@@ -40,7 +40,7 @@ async function setup(h) {
async function submit(h, e, body = REQUEST) {
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/' } });
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));
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 () => {
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
@@ -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' });
const bare = await patch(editorToken(), { status: 'accepted' });
assert.strictEqual(bare.status, 422);
assert.strictEqual(bare.status, 422);
assert.strictEqual(bare.json.code, 'correction.note_required');
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(), {
const ok = await patch(editorToken(), {
status: 'accepted', note: INTERNAL, correction_note: NOTE,
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
@@ -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');
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.
// 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.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.
// The public page, without JavaScript: the corrected text, the note, and the history.
const page = await req(h, 'GET', `/e/${e.slug}`);
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
@@ -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');
assert.ok(review.dateModified, 'the Review says it was modified');
const feed = JSON.parse((await req(h, 'GET', '/feed.json')).text);
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]));
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');
const upd = events.filter((x) => x.event_type === 'reviews.summary.updated');
assert.strictEqual(upd.length, 1);
assert.strictEqual(upd.length, 1);
assert.deepStrictEqual(upd[0].payload.correction, { note: NOTE, corrects: 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,
@@ -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');
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' } });
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(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.
// 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 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' } });
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(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.' } });
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);
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(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.
// 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.');
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,
@@ -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',
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(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');
assert.strictEqual(s.published_revision, 3, 'a correction is published at once');
// An editor corrects without any request.
// 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"'
@@ -203,7 +203,7 @@ t('a correction of approved AI text is AI-assisted, never "written by a person"'
try {
try {
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
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 }));
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: {
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 },
overview: 'All sampled reviewers recommend it.', overview_signals: sigs, workflow: { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ', version: 1 },
} });
} });
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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 };
@@ -14,7 +14,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
@@ -14,7 +14,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
const CAPS = ['reviews.entity.manage'];
const CAPS = ['reviews.entity.manage'];
const token = (sub, actorType, extra) => serviceToken({ sub, actorType, cap: CAPS, extra });
const token = (sub, actorType, extra) => serviceToken({ sub, actorType, cap: CAPS, extra });
const entity = (name) => ({ name, kind: 'product' });
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 () => {
t('an app or module cannot act as an editor by naming them in X-OV-Subject', async () => {
const h = await boot();
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
@@ -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}`) });
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.status, 403, `${type} ${JSON.stringify(extra)}: ${r.text}`);
assert.strictEqual(r.json.code, 'subject.not_delegated');
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(); }
} finally { await h.stop(); }
@@ -12,6 +12,7 @@ const path = require('path');
@@ -12,6 +12,7 @@ const path = require('path');
const { serviceAuth, ids } = require('openvibe-contracts');
const { serviceAuth, ids } = require('openvibe-contracts');
const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const { load } = require('../server/config');
const { load } = require('../server/config');
+const { testDb } = require('./db');
const { start } = require('../server/index');
const { start } = require('../server/index');
const ISSUER = 'https://network.test';
const ISSUER = 'https://network.test';
@@ -41,7 +42,7 @@ function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.reviews', exp =
@@ -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);
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. */
/** In-memory OpenVibe.Sources: items in change order, sources, removal. */
function fakeSources() {
function fakeSources() {
@@ -73,9 +74,9 @@ function fakeSources() {
@@ -73,9 +74,9 @@ function fakeSources() {
let m = u.pathname.match(/^\/api\/v1\/items\/(itm_[0-9A-Z]+)$/);
let m = u.pathname.match(/^\/api\/v1\/items\/(itm_[0-9A-Z]+)$/);
if (m) {
if (m) {
const it = items.get(m[1]);
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);
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') {
if (u.pathname === '/api/v1/items') {
const after = Number(u.searchParams.get('after') || 0);
const after = Number(u.searchParams.get('after') || 0);
@@ -84,11 +85,11 @@ function fakeSources() {
@@ -84,11 +85,11 @@ function fakeSources() {
const page = all.slice(0, limit);
const page = all.slice(0, limit);
const srcs = {};
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 }; }
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-]+)$/);
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;
return api;
@@ -130,16 +131,16 @@ function fakeCommunity() {
@@ -130,16 +131,16 @@ function fakeCommunity() {
const ref = JSON.parse(opts.body).ref;
const ref = JSON.parse(opts.body).ref;
const key = `${ref.service}:${ref.type}:${ref.id}`;
const key = `${ref.service}:${ref.type}:${ref.id}`;
if (!threads.has(key)) threads.set(key, { id: `thr_${threads.size + 1}`, comments: [] });
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)?$/);
const m = u.pathname.match(/^\/api\/v1\/comments\/threads\/([^/]+)(\/comments)?$/);
if (m) {
if (m) {
const t = [...threads.values()].find((x) => x.id === m[1]);
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
@@ -165,12 +166,14 @@ async function boot({ env = {}, sources = fakeSources(), community = null, dbPat
if (host === 'community.test' && community) return community.fetch(url, opts);
if (host === 'community.test' && community) return community.fetch(url, opts);
throw new Error(`unexpected outbound fetch ${url}`);
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({
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() {} },
tokens: { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
});
const base = `http://127.0.0.1:${h.server.address().port}`;
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 } = {}) {
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 } = {
@@ -195,8 +198,8 @@ async function req(h, method, p, { token, body, form, headers = {}, cookie } = {
const cookieFor = (token) => `ov_token=${token}`;
const cookieFor = (token) => `ov_token=${token}`;
/** Every envelope in the outbox, oldest first. */
/** 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'] });
const IMPORT = () => serviceToken({ client: 'sources-sync', cap: ['reviews.signal.import'] });
@@ -222,7 +225,7 @@ async function createEntity(h, body) {
@@ -222,7 +225,7 @@ async function createEntity(h, body) {
async function deliver(h, envelope, { secret = WEBHOOK_SECRET, v1Only = false, now } = {}) {
async function deliver(h, envelope, { secret = WEBHOOK_SECRET, v1Only = false, now } = {}) {
const raw = JSON.stringify({ event: envelope, seq: 1 });
const raw = JSON.stringify({ event: envelope, seq: 1 });
const headers = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret, { now });
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 = {}) {
function sourcesEvent(type, item, extra = {}) {
@@ -10,15 +10,15 @@ const {
@@ -10,15 +10,15 @@ const {
const t = suite('merge');
const t = suite('merge');
-function snapshot(h, ids) {
+async function snapshot(h, ids) {
const out = {};
const out = {};
for (const id of ids) {
for (const id of ids) {
out[id] = {
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;
return out;
@@ -31,7 +31,7 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
@@ -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' }] });
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, 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' }));
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[a.id].signals.length, 3);
assert.strictEqual(before[b.id].signals.length, 2);
assert.strictEqual(before[b.id].signals.length, 2);
@@ -48,7 +48,7 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
@@ -48,7 +48,7 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
assert.strictEqual(m.json.signals.length, 2);
assert.strictEqual(m.json.signals.length, 2);
// Nothing was rewritten: signals keep their entity; the target's aggregate spans both.
// 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[b.id].signals, before[b.id].signals);
assert.deepStrictEqual(during[a.id].signals, before[a.id].signals);
assert.deepStrictEqual(during[a.id].signals, before[a.id].signals);
assert.deepStrictEqual(during[b.id].aliases, before[b.id].aliases);
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
@@ -64,12 +64,12 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
assert.strictEqual(res.json.via.id, b.id);
assert.strictEqual(res.json.via.id, b.id);
// A new item for B while merged is still attributed to B (and shown under A).
// 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' }));
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);
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' } });
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);
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.
// 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[a.id], before[a.id]);
assert.deepStrictEqual(after[b.id].entity, before[b.id].entity);
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
@@ -78,15 +78,15 @@ t('merge → split round trip restores attribution, aliases and aggregates exact
assert.strictEqual(after[b.id].signals.length, 3);
assert.strictEqual(after[b.id].signals.length, 3);
// Audited: the link row, the log and the events.
// 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.created_by, EDITOR);
assert.strictEqual(link.note, 'Same game, duplicate listing');
assert.strictEqual(link.note, 'Same game, duplicate listing');
assert.strictEqual(link.ended_by, EDITOR);
assert.strictEqual(link.ended_by, EDITOR);
assert.strictEqual(link.end_note, 'Actually two different editions');
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(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());
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']);
assert.deepStrictEqual(ev, ['reviews.entity.merged', 'reviews.entity.split']);
const hist = await req(h, 'GET', `/e/${a.slug}/history`);
const hist = await req(h, 'GET', `/e/${a.slug}/history`);
assert.match(hist.text, /Portal Two<\/a> merged into/);
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
@@ -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' }] });
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: 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' }));
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' } });
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: {
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,
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);
assert.strictEqual(w.status, 201, w.text);
await req(h, 'POST', `/api/v1/entities/${b.slug}/split`, { token: editorToken(), body: { note: 'different products' } });
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.strictEqual(s.flagged, 1);
assert.match(s.flag_reason, /split/);
assert.match(s.flag_reason, /split/);
const page = await req(h, 'GET', `/api/v1/entities/${a.slug}`, { token: editorToken() });
const page = await req(h, 'GET', `/api/v1/entities/${a.slug}`, { token: editorToken() });
@@ -70,7 +70,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
@@ -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');
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' }] });
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 }));
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');
let sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
assert.ok(!sm.text.includes(`/e/${e.slug}`), 'thin: not in the sitemap');
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: {
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
@@ -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()) });
const after = await req(h, 'GET', `/e/${e.slug}`, { cookie: cookieFor(readerToken()) });
assert.match(after.text, /Is the co-op counted here\?/);
assert.match(after.text, /Is the co-op counted here\?/);
// Referenced, not duplicated: the comment text exists in Community only.
// 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(); }
} finally { await h.stop(); }
});
});
@@ -140,9 +140,9 @@ t('editors work with plain forms: create, resolve an ambiguous item, summarise,
@@ -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\//);
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' } });
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);
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.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 });
const edit = await req(h, 'GET', '/e/widget-3000/edit', { cookie: ed });
assert.match(edit.text, new RegExp(`<option value="${sig.id}"`));
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,
@@ -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);
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.' } });
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(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.
// 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' } });
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);
assert.strictEqual(xs.status, 403);
@@ -52,19 +52,21 @@ t('the service manifest proposal is a valid registry.service-manifest@1 and list
@@ -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 () => {
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);
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);
assert.strictEqual(AUTHORITY_TABLES.length, 9);
for (const name of AUTHORITY_TABLES) assert.ok(tables.has(name), name);
for (const name of AUTHORITY_TABLES) assert.ok(tables.has(name), name);
for (const name of [...tables].filter((n) => n.startsWith('review_'))) {
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.
// Booting the service seeds nothing either.
const h = await boot();
const h = await boot();
try {
try {
for (const name of ['review_entities', 'review_signals', 'review_summaries', 'review_aggregates', 'review_sources', 'review_source_items']) {
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(); }
} finally { await h.stop(); }
assert.ok(!fs.existsSync(path.join(__dirname, '..', 'seeds')), 'no seed directory');
assert.ok(!fs.existsSync(path.join(__dirname, '..', 'seeds')), 'no seed directory');
@@ -59,9 +59,9 @@ t('strong identifiers resolve; names only propose candidates; conflicts wait for
@@ -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.
// 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 } });
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);
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']]);
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);
assert.strictEqual(log, 2);
// Strong identifiers are unique across entities.
// Strong identifiers are unique across entities.
@@ -29,7 +29,7 @@ t('items without an entity wait unmatched; a source binding settles them; every
@@ -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.item.resolution, 'unmatched');
assert.strictEqual(out.signal, null);
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 e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
const agg = await aggregateOf(h, e.slug);
const agg = await aggregateOf(h, e.slug);
@@ -48,12 +48,12 @@ t('items without an entity wait unmatched; a source binding settles them; every
@@ -48,12 +48,12 @@ t('items without an entity wait unmatched; a source binding settles them; every
assert.ok('license_note' in s.provenance);
assert.ok('license_note' in s.provenance);
}
}
// The table itself refuses a signal without 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.
// 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.
// 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.ok(!/summary|review_text|body/.test(row.fields));
assert.strictEqual(row.title, null);
assert.strictEqual(row.title, null);
}
}
@@ -67,7 +67,7 @@ t('an entity without signals has no aggregate (null, not 0) and no aggregate row
@@ -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);
const agg = await aggregateOf(h, e.slug);
assert.strictEqual(agg.aggregate, null);
assert.strictEqual(agg.aggregate, null);
assert.strictEqual(agg.aggregate_revision, 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(); }
} finally { await h.stop(); }
});
});
@@ -96,10 +96,10 @@ t('source removal (webhook) withdraws the signal and the next aggregate revision
@@ -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.on_scale.value, 4);
assert.strictEqual(agg.aggregate.result.components.rating.count, 100);
assert.strictEqual(agg.aggregate.result.components.rating.count, 100);
assert.ok(!agg.aggregate.result.inputs.some((i) => i.source_item_id === b.id));
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.strictEqual(sig.status, 'withdrawn');
assert.match(sig.status_reason, /licence 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.length, 1);
assert.strictEqual(ev[0].payload.source_item_id, b.id);
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
@@ -111,7 +111,7 @@ t('source removal (webhook) withdraws the signal and the next aggregate revision
agg = await aggregateOf(h, e.slug);
agg = await aggregateOf(h, e.slug);
assert.strictEqual(agg.aggregate, null, 'no qualifying signal → no aggregate');
assert.strictEqual(agg.aggregate, null, 'no qualifying signal → no aggregate');
assert.strictEqual(agg.aggregate_revision, rev + 2, 'the disappearance is itself a recorded revision');
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);
assert.strictEqual(rows[rows.length - 1].result, null);
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
@@ -135,7 +135,7 @@ t('an item update supersedes the signal; the pull path follows Sources in change
@@ -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);
assert.strictEqual(again.json.pull.applied, 1);
agg = await aggregateOf(h, e.slug);
agg = await aggregateOf(h, e.slug);
assert.strictEqual(agg.aggregate.result.components.recommendation.percent, 50);
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.deepStrictEqual(sigs.map((s) => s.status), ['superseded', 'active']);
assert.strictEqual(sigs[0].superseded_by, sigs[1].id);
assert.strictEqual(sigs[0].superseded_by, sigs[1].id);
assert.strictEqual(sigs[1].item_revision, 2);
assert.strictEqual(sigs[1].item_revision, 2);
@@ -166,7 +166,7 @@ t('a Sources outage changes nothing and invents nothing', async () => {
@@ -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.
// 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()));
const ev = await deliver(h, sourcesEvent('sources.item.created', steamItem()));
assert.strictEqual(ev.json.outcome, 'queued');
assert.strictEqual(ev.json.outcome, 'queued');
- assert.strictEqual(h.sync.pending(), 1);
+ assert.strictEqual(await h.sync.pending(), 1);
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
@@ -177,7 +177,7 @@ t('every produced event is a valid events.event-envelope@1', async () => {
@@ -177,7 +177,7 @@ t('every produced event is a valid events.event-envelope@1', async () => {
const it = steamItem({ votedUp: true });
const it = steamItem({ votedUp: true });
await importItem(h, it);
await importItem(h, it);
await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(it.id), { reason: 'takedown' }));
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.added'));
assert.ok(events.some((x) => x.event_type === 'reviews.signal.removed'));
assert.ok(events.some((x) => x.event_type === 'reviews.signal.removed'));
assert.ok(events.some((x) => x.event_type === 'reviews.index_document.upserted'));
assert.ok(events.some((x) => x.event_type === 'reviews.index_document.upserted'));
@@ -18,17 +18,17 @@ async function setup(h) {
@@ -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 e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
const items = [];
const items = [];
for (const v of [true, true, true, false]) items.push((await importItem(h, steamItem({ votedUp: v }))).item);
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) };
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 () => {
t('AI text never yields a rating: rating fields and rating text are refused, the aggregate is untouched', async () => {
const h = await boot();
const h = await boot();
try {
try {
const { e, pos } = await setup(h);
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 } });
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.status, 422);
assert.strictEqual(withField.json.code, 'summary.rating_forbidden');
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
@@ -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.status, 422, text);
assert.strictEqual(r.json.code, 'summary.rating_in_text', 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.
// 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 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 } });
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
@@ -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}`);
const pageBefore = await req(h, 'GET', `/e/${e.slug}`);
assert.match(pageBefore.text, /No published summary/);
assert.match(pageBefore.text, /No published summary/);
assert.ok(!pageBefore.text.includes('Most sampled reviewers recommend it'));
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');
const feed = await req(h, 'GET', '/feed.json');
assert.strictEqual(JSON.parse(feed.text).items.length, 0);
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
@@ -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}`);
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, /AI-generated by workflow reviews\.summarize_entity v1, reviewed by a person/);
assert.match(page.text, /Most sampled reviewers recommend it/);
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);
assert.strictEqual(ev.length, 1);
// The review JSON-LD carries no reviewRating: a summary never states a rating.
// 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]));
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
@@ -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.
// The source removes the only negative review: the con loses its support.
const negItem = sigs.find((s) => s.recommended === 0).source_item_id;
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' }));
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.strictEqual(s.flagged, 1);
assert.match(s.flag_reason, /withdrawn/);
assert.match(s.flag_reason, /withdrawn/);
const page = await req(h, 'GET', `/api/v1/entities/${e.slug}`, { token: editorToken() });
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
@@ -141,10 +141,10 @@ t('human summaries cite signals for every point; a withdrawn cited signal flags
assert.strictEqual(direct.status, 409);
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' } });
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);
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.flagged, 0);
assert.strictEqual(fresh.published_revision, pending.number);
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(); }
} finally { await h.stop(); }
});
});
@@ -14,7 +14,7 @@ const {
@@ -14,7 +14,7 @@ const {
const t = suite('threat');
const t = suite('threat');
const ORIGIN = 'http://reviews.test';
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 () => {
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();
const h = await boot();
@@ -26,7 +26,7 @@ t('cross-site writes are refused on pages and on the cookie-authenticated API; s
@@ -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({ 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({ '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((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: 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);
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
@@ -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');
assert.strictEqual(r.json.code, 'request.cross_site');
r = await api('Via cookie, cross-site fetch', { 'Sec-Fetch-Site': 'cross-site' }, { cookie: ed });
r = await api('Via cookie, cross-site fetch', { 'Sec-Fetch-Site': 'cross-site' }, { cookie: ed });
assert.strictEqual(r.status, 403, r.text);
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 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 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');
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
@@ -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) });
const huge = await send('A very long target id follows.', { target_id: 'x'.repeat(100000) });
assert.strictEqual(huge.status, 201);
assert.strictEqual(huge.status, 201);
assert.strictEqual(huge.json.correction.target_id.length, 100);
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');
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}`);
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.');
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
@@ -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 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) } });
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);
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`);
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' }] });
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 }));
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);
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`]) {
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);
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
@@ -110,7 +110,7 @@ t('AI text cannot spell a rating out in words; out-of-scale source ratings are n
try {
try {
const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
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 }));
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 AI = serviceToken({ client: 'ai', cap: ['reviews.summary.propose'] });
const wf = { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ' };
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.']) {
for (const text of ['Easily four and a half stars.', 'A nine out of ten experience.', 'A five-star puzzle game.']) {