openvibers/openvibe.community Commits
Community on PostgreSQL (ADR-035, roadmap WS-X2, repository pass G3).
Changes
@@@ -57,8 +57,15 @@ OV_CHAT_URL=https://openvibe.chat# community this site's own database is the authority (native API, pages, raw text)# With 'community' the OAuth client above also needs identity.subject.resolve (author names).PASTES_AUTHORITY=live--# SQLite database (production unit: /var/lib/openvibe-community/community.db). Comments, the--# forum, Pulse and the relay's bookkeeping live here in every mode; pastes too with 'community'.++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh community: the runtime role through++# PgBouncer, the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit++# counters). Comments, the forum, Pulse and the relay's bookkeeping live there in every mode; pastes too with++# 'community'. 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.COMMUNITY_DB_PATH=./data/community.db# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)#VIEW_HASH_SECRET=@@@ -6,11 +6,13 @@ on:jobs:# install, node --check, npm test (with test/n-1.test.js: the previous release's pages, forms and SDK# calls, and its SQL, against this one, from test/fixtures/n-1) and openvibe-contracts-check: the shared-- # test job (Track Q).++ # test job (Track Q). 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:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:contracts-service: community++ 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@@@ -9,7 +9,7 @@ the rules. It is the home of **pastes** (code, text and screenshots with a link)**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe productembeds, and **Pulse**, the network's public activity. Submissions follow.--It is a small Node/Express app (CommonJS, no framework, one SQLite database) that++It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) thatserver-renders every page — crawlers and no-JS readers get the whole thing — and adds alittle progressive JavaScript for pagination, copy buttons and the upload path.@@@ -20,7 +20,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,categories), Pulse (the network's public activity) and the Discord relay- the `community.*` events, Search documents for public threads, and the `community.profile` user module--- one SQLite database (`/var/lib/openvibe-community/community.db` in production)++- one PostgreSQL database (`ov_community` on the host's data role, ADR-035; schema in [migrations/](migrations/);++ embedded PGlite in development), with Valkey for the per-actor limit counters## Does not own@@@ -34,8 +35,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)- OpenVibe.Live only when `PASTES_AUTHORITY=live` (the rollback mode), and for old VOD/clip comment imports--- `openvibe-contracts` v0.71.0, `openvibe-sdk` v0.12.0 (events outbox, per-actor limits),-- `openvibe-shared` v1.25.0, pinned by release tarball++- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.0 (events outbox, per-actor limits),++ `openvibe-shared` v1.28.0, pinned by release tarball## How it fits the network@@@ -105,14 +106,9 @@ Moments feeds read it.thing keeping it unlisted. Existing slugs never change and keep working.- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.--Moving the data: `node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff--2026-08-20T03:00:26Z]` imports Media's `openvibe.media.pastes-export` bundle. It checks the--bundle's format, counts and sha256 first, is idempotent (upsert by `legacy_media_id`; a newer--bundle adds and updates, counters never go down, local edits and deletes stay), maps Live user--ids to subjects through the Network (rows before the cutoff are also checked as Network ids;--disagreement or no answer imports the row ownerless and records an `import_hold`), keeps AI--moment pastes ownerless with origin `ai`, and prints a reconciliation report (also stored in--`migration_runs`). `--dry-run` rolls everything back.++Moving the data: Media's `openvibe.media.pastes-export` bundle was imported once (Wave 5, 2026-09-22; the ledger is++`legacy_id_map`, the report `migration_runs`). The importer (`scripts/import-pastes.js`) was retired with the move to++PostgreSQL and is in git history.`/by-user/:username` and `?username=` look the name up in `subject_projection` (the Networkhas no service-token lookup by username), so a person appears there once Community has seen@@@ -161,7 +157,7 @@ API and machine endpoints:| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only--when the required `db` check fails (a real query on Community's SQLite). `network_jwks` (the++when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (theNetwork signing key; without it nobody can sign in or write as a signed-in viewer or service),`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready@@@ -250,39 +246,10 @@ item's comments.3. Service-side, the same answer: `GET /api/v1/comments/threads/<id>?sort=new` with Live's tokenreturns the comments both pages render (`test/comments.test.js` checks the page against it).--**Moving Live's old rows** (`scripts/import-live-comments.js`, `server/comments/live-import.js`):--reads Live's `comments` table read-only, puts every row in exactly one bucket and prints the--reconciliation `read = imported + held + excluded`. Imported rows keep their text and times--(edits keep `edited_at`); the ledger is `legacy_id_map` (`live`/`comment`/<live id> → comment--id), so re-running imports nothing twice. Authors become Network subjects from Live's--`linked_accounts.subject_id` and the Network's identity map (system `live`); a row whose author--maps nowhere (`unmapped_author`), maps two ways (`ambiguous_author`) or whose parent is held--(`parent_held`) is **held** in `import_hold` (source type `live_comment`) and listed, and a later--run imports it once the mapping exists. Deleted rows and replies under them, orphans and empty--messages are **excluded**. Dry run is the default (everything rolled back); `--apply` requires--`--backup <new file>`, an online backup of Community's database that is integrity-checked--before anything is written. Community must be deployed first (the script refuses a database--without `comments.edited_at` rather than migrate it from a dry run).----Production (on the host, as the service's own user and environment through `systemd-run`, so--no file in `/var/lib/openvibe-community` ends up owned by root). Order: deploy Community → dry--run → apply → deploy Live → apply again (catches comments Live wrote in between; the ledger--skips the rest) → verify as above.----```--RUN="sudo systemd-run --wait --pipe --collect -p User=ubuntu -p Group=ubuntu \-- -p WorkingDirectory=/opt/openvibe.community -p EnvironmentFile=/etc/openvibe/community.env \-- -E NODE_ENV=production -E COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db"--ARGS="--live-db /opt/openvibe.live/data/live.db --community-db /var/lib/openvibe-community/community.db"----$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS # dry run--$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS --apply \-- --backup /var/lib/openvibe-community/community.pre-live-comments-$(date -u +%Y%m%dT%H%M%SZ).db--```----Held rows: `sqlite3 /var/lib/openvibe-community/community.db "SELECT * FROM import_hold WHERE--source_type = 'live_comment'"`. Rollback: stop Community, copy the backup over `community.db`--(remove `community.db-wal`/`-shm`), start it; Live's own rows were never changed.++**Moving Live's old rows**: Live's VOD and clip comments were imported once (ledger `legacy_id_map`, `live`/`comment`;++held rows in `import_hold`, source type `live_comment`). The importer (`scripts/import-live-comments.js`,++`server/comments/live-import.js`) was retired with the move to PostgreSQL and is in git history, as is C-24's++`scripts/migrate-screenshot-refs.js` (applied 2026-09-25: no paste keeps a legacy media reference).Embedding it — server-side, from another product's backend (the usual way; the person is theone your own session says it is):@@@ -546,7 +513,7 @@ Community checks service tokens against these capabilities (manifests in| `community.post.create` | active in contracts (v0.7.0) | forum writes || `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |--This repository pins `openvibe-contracts` v0.71.0, which knows every id above, so they all go++This repository pins `openvibe-contracts` v0.76.0, which knows every id above, so they all gothrough the library's `capabilities.check`. `server/identity/capabilities.js` still decides an idthe installed contracts do not know locally, with the library's own matching rule (the exact idor a `prefix.*` grant).@@@ -618,7 +585,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots || `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) || `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |--| `COMMUNITY_DB_PATH` | `./data/community.db` | SQLite file (the systemd unit sets `/var/lib/openvibe-community/community.db`) |++| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset (development: embedded PGlite in `data/pglite`) | PostgreSQL through PgBouncer, and the owner's direct connection for migrations (written by OpenVibe.Host `roles/data/add-service.sh community`) |++| `VALKEY_URL`, `VALKEY_PREFIX` | unset | per-actor limit counters shared across processes |++| `COMMUNITY_DB_PATH` | `./data/community.db` | the SQLite file of releases before PostgreSQL, read once by `scripts/migrate-to-postgres.js` || `API_CORS_ORIGINS` | Live, Media, Network, Tools, Games origins | Browser origins that may call `/api/v1/comments` and `/api/v1/pulse` with a Bearer JWT || `DISCORD_RELAY_ENABLED` | off | `true` turns the Discord relay on ([docs/discord-relay.md](docs/discord-relay.md)) || `DISCORD_RELAY_POLL_MS` / `DISCORD_RELAY_BACKOFF_MS` / `DISCORD_RELAY_MAX_ATTEMPTS` | `30000` / `30000` / `6` | Relay sender cadence, first retry delay, attempts before `failed` (the dead letter) |@@@ -694,11 +663,14 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, TProduction deploys with `sudo ovhost deploy community` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.community`, install on a lockfile change, restart, wait for `/api/ready`).--The unit is `openvibe-community.service` on `127.0.0.1:4200`, the env file `/etc/openvibe/community.env`. After a++The unit is `openvibe-community.service` on `127.0.0.1:4200`, the env file `/etc/openvibe/community.env`. The database is++`ov_community` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh community` 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-community/community.db` stays read-only for 7 days as the rollback. After adeploy, record the N-1 fixtures (`npm run n-1:record`).Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the--restart; afterwards `sudo ovhost rollback community --to <sha>`. Nothing blocks a rollback: the schema--code only adds tables and columns.++restart; afterwards `sudo ovhost rollback community --to <sha>`. Migrations only add tables and columns.The schemais created idempotently at boot in every mode (comments, the forum, Pulse and the relay live@@@ -724,7 +696,7 @@ server/graceful.js SIGTERM: stop timers and scans, drain HTTP (4 s), settle the relays, close the DB, exit 0 (5 s at most)app.js Express app factory: middleware, routesconfig.js env → config-- db.js SQLite (better-sqlite3): schema, opened at COMMUNITY_DB_PATH++ db.js PostgreSQL (openvibe-sdk/db): initDb/getDb, migrations/ applied at bootauth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuthidentity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymousidentity/network.js Network resolve-batch client + subject_projection cache@@@ -735,7 +707,6 @@ server/pastes/api.js native /api/pastes/* ('community')pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapespastes/store.js pure SQL over pastes / versions / likes / comments / projections-- pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)pastes/source.js where pages read pastes from (Live or the store)pastes/catalog.js recent public pastes: trending, related, language filtercomments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),@@@ -761,8 +732,7 @@ server/public/ css/community.css, js/community.js, favicon.svg, og-default.png(openvibe-shared is the pinned OpenVibe.Shared v1.25.0 release, installed by npm)deploy/ systemd unit, nginx vhost--scripts/import-pastes.js Media paste bundle importer--scripts/import-live-comments.js Live's VOD/clip comments → Community threads (dry run by default)++scripts/migrate-to-postgres.js the one-time SQLite → PostgreSQL import (runSqliteMigration; --pglite rehearsal)test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits@@@ -4,10 +4,10 @@"code": true,"wave": "W5","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express, better-sqlite3",++ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4200,"domain": "openvibe.community",-- "contracts": "openvibe-contracts v0.71.0",++ "contracts": "openvibe-contracts v0.76.0","capabilities": ["community.paste.create","community.paste.write",@@@ -2,8 +2,8 @@Description=OpenVibe.Community — The people of OpenVibe (pastes; forums and submissions to follow)# Live (openvibe-live) and Network (openvibe-network) should be up first, but# Community degrades gracefully if they aren't (paste pages answer 502, sign-in retries).--After=network.target--Wants=openvibe-live.service openvibe-network.service++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-live.service openvibe-network.service postgresql.service pgbouncer.service valkey-server.service[Service]Type=simple@@@ -21,8 +21,8 @@ NoNewPrivileges=truePrivateTmp=trueProtectSystem=strictProtectHome=true--# Community's own database (used once PASTES_AUTHORITY=community; untouched before that).--# The code tree stays read-only; the database lives in /var/lib/openvibe-community.++# The code tree stays read-only. Community's database is PostgreSQL (ov_community, ADR-035); /var/lib/openvibe-community++# keeps the pre-PostgreSQL SQLite file (read-only) for the rollback window and the one-time import.StateDirectory=openvibe-communityEnvironment=COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.dbnnew file mode 100644@@@ -0,0 +1,514 @@++-- phase: expand++-- OpenVibe.Community 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.++-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).++CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$++BEGIN++ IF t IS NULL THEN RETURN NULL; END IF;++ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;++ IF t ~ '\d\d:\d\d(:\d\d(\.\d+)?)?\s*(Z|[+-]\d\d(:?\d\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;++ RETURN t::timestamp;++EXCEPTION WHEN others THEN RETURN NULL;++END $$;++CREATE FUNCTION ov_now() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') $$;++CREATE FUNCTION ov_now_iso() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;++CREATE FUNCTION datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$++DECLARE ts timestamp := ov_ts(t);++BEGIN++ IF ts IS NULL THEN RETURN NULL; END IF;++ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;++ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');++EXCEPTION WHEN others THEN RETURN NULL;++END $$;++CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;++-- The forum's hot rank (server/forum/store.js): (score + 1) / (age in hours + 2)^1.5, the Hacker News gravity.++CREATE FUNCTION ov_hot(score double precision, age_hours double precision) RETURNS double precision LANGUAGE sql IMMUTABLE AS $$ SELECT (COALESCE(score, 0) + 1) / power(GREATEST(COALESCE(age_hours, 0), 0) + 2, 1.5) $$;++++CREATE TABLE pastes (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ slug text COLLATE "C" UNIQUE NOT NULL,++ owner_subject text COLLATE "C",++ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),++ type text COLLATE "C" NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),++ title text COLLATE "C" NOT NULL DEFAULT 'Untitled',++ content text COLLATE "C",++ language text COLLATE "C" DEFAULT 'text',++ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),++ screenshot_url text COLLATE "C",++ media_ref text COLLATE "C",++ stream_ref text COLLATE "C",++ metadata text COLLATE "C",++ burn_after_read bigint NOT NULL DEFAULT 0,++ forked_from bigint REFERENCES pastes(id) ON DELETE SET NULL,++ pinned bigint NOT NULL DEFAULT 0,++ views bigint NOT NULL DEFAULT 0,++ unique_views bigint NOT NULL DEFAULT 0,++ copies bigint NOT NULL DEFAULT 0,++ likes bigint NOT NULL DEFAULT 0,++ is_nsfw bigint NOT NULL DEFAULT 0,++ ai_summary text COLLATE "C",++ ai_tags text COLLATE "C",++ ai_analyzed_at text COLLATE "C",++ revision bigint NOT NULL DEFAULT 1,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ deleted_at text COLLATE "C",++ legacy_media_id bigint UNIQUE,++ legacy_user_id bigint++);++++CREATE TABLE paste_versions (++ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ revision bigint NOT NULL,++ title text COLLATE "C",++ content text COLLATE "C",++ language text COLLATE "C",++ edited_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (paste_id, revision)++);++++CREATE TABLE paste_likes (++ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ subject_id text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (paste_id, subject_id)++);++++CREATE TABLE paste_comments (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ author_subject text COLLATE "C",++ anon_name text COLLATE "C",++ parent_id bigint REFERENCES paste_comments(id) ON DELETE CASCADE,++ message text COLLATE "C" NOT NULL,++ is_deleted bigint NOT NULL DEFAULT 0,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ legacy_media_id bigint UNIQUE++);++++CREATE TABLE paste_visits (++ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ visitor text COLLATE "C" NOT NULL,++ first_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ last_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ visits bigint NOT NULL DEFAULT 1,++ PRIMARY KEY (paste_id, visitor)++);++++CREATE TABLE subject_projection (++ subject_id text COLLATE "C" PRIMARY KEY,++ username text COLLATE "C",++ display_name text COLLATE "C",++ avatar_url text COLLATE "C",++ profile_color text COLLATE "C",++ refreshed_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE TABLE migration_runs (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ source text COLLATE "C" NOT NULL,++ started_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ finished_at text COLLATE "C",++ report text COLLATE "C"++);++++CREATE TABLE legacy_id_map (++ source_system text COLLATE "C" NOT NULL,++ source_type text COLLATE "C" NOT NULL,++ source_id text COLLATE "C" NOT NULL,++ target_type text COLLATE "C" NOT NULL,++ target_id text COLLATE "C" NOT NULL,++ PRIMARY KEY (source_system, source_type, source_id)++);++++CREATE TABLE comment_threads (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ ref_service text COLLATE "C" NOT NULL,++ ref_type text COLLATE "C" NOT NULL,++ ref_id text COLLATE "C" NOT NULL,++ ref_label text COLLATE "C",++ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')),++ comment_count bigint NOT NULL DEFAULT 0,++ created_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ access_id text COLLATE "C",++ UNIQUE (ref_service, ref_type, ref_id)++);++++CREATE TABLE comments (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ thread_id bigint NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,++ parent_id bigint REFERENCES comments(id) ON DELETE CASCADE,++ author_subject text COLLATE "C",++ anon_name text COLLATE "C",++ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai')),++ message text COLLATE "C" NOT NULL,++ score bigint NOT NULL DEFAULT 0,++ upvotes bigint NOT NULL DEFAULT 0,++ downvotes bigint NOT NULL DEFAULT 0,++ reply_count bigint NOT NULL DEFAULT 0,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ deleted_at text COLLATE "C",++ deleted_by text COLLATE "C",++ edited_at text COLLATE "C"++);++++CREATE TABLE comment_votes (++ comment_id bigint NOT NULL REFERENCES comments(id) ON DELETE CASCADE,++ subject_id text COLLATE "C" NOT NULL,++ value bigint NOT NULL CHECK(value IN (-1, 1)),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (comment_id, subject_id)++);++++CREATE TABLE space_groups (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ slug text COLLATE "C" UNIQUE NOT NULL,++ name text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ position bigint NOT NULL DEFAULT 0++);++++CREATE TABLE spaces (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ slug text COLLATE "C" UNIQUE NOT NULL,++ name text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),++ created_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ members_only_owner text COLLATE "C",++ thread_kind text COLLATE "C" NOT NULL DEFAULT 'discussion',++ style text COLLATE "C" NOT NULL DEFAULT 'feed',++ votes bigint NOT NULL DEFAULT 1,++ reactions bigint NOT NULL DEFAULT 1,++ group_id bigint REFERENCES space_groups(id) ON DELETE SET NULL,++ parent_id bigint REFERENCES spaces(id) ON DELETE SET NULL,++ position bigint NOT NULL DEFAULT 0++);++++CREATE TABLE categories (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,++ slug text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ position bigint NOT NULL DEFAULT 0,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ UNIQUE (space_id, slug)++);++++CREATE TABLE threads (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,++ slug text COLLATE "C" NOT NULL,++ title text COLLATE "C" NOT NULL,++ author_subject text COLLATE "C",++ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),++ pinned bigint NOT NULL DEFAULT 0,++ locked bigint NOT NULL DEFAULT 0,++ score bigint NOT NULL DEFAULT 0,++ reply_count bigint NOT NULL DEFAULT 0,++ last_activity_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ deleted_at text COLLATE "C",++ members_only_owner text COLLATE "C",++ kind text COLLATE "C" NOT NULL DEFAULT 'discussion',++ status text COLLATE "C",++ category_id bigint REFERENCES categories(id) ON DELETE SET NULL,++ external_key text COLLATE "C",++ views bigint NOT NULL DEFAULT 0,++ crosspost_of bigint REFERENCES threads(id) ON DELETE SET NULL,++ UNIQUE (space_id, slug)++);++++CREATE TABLE posts (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,++ author_subject text COLLATE "C",++ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),++ is_opening bigint NOT NULL DEFAULT 0,++ body_markdown text COLLATE "C" NOT NULL,++ revision bigint NOT NULL DEFAULT 1,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ deleted_at text COLLATE "C",++ relay_author text COLLATE "C"++);++++CREATE TABLE post_versions (++ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,++ revision bigint NOT NULL,++ body_markdown text COLLATE "C" NOT NULL,++ edited_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (post_id, revision)++);++++CREATE TABLE thread_votes (++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,++ subject_id text COLLATE "C" NOT NULL,++ value bigint NOT NULL CHECK(value IN (-1, 1)),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (thread_id, subject_id)++);++++CREATE TABLE attachments (++ media_id text COLLATE "C" PRIMARY KEY,++ owner_subject text COLLATE "C" NOT NULL,++ post_id bigint REFERENCES posts(id) ON DELETE CASCADE,++ position bigint NOT NULL DEFAULT 0,++ filename text COLLATE "C",++ mime text COLLATE "C" NOT NULL,++ size_bytes bigint NOT NULL,++ url text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE TABLE post_reactions (++ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,++ subject_id text COLLATE "C" NOT NULL,++ reaction text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (post_id, subject_id)++);++++CREATE TABLE post_pastes (++ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,++ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,++ position bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (post_id, paste_id)++);++++CREATE TABLE space_chat_rooms (++ space_id bigint PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,++ room_id text COLLATE "C",++ room_slug text COLLATE "C" NOT NULL,++ room_name text COLLATE "C" NOT NULL,++ room_kind text COLLATE "C" NOT NULL DEFAULT 'community',++ room_visibility text COLLATE "C" NOT NULL DEFAULT 'public',++ attached_by text COLLATE "C",++ attached_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE TABLE relay_mappings (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,++ direction text COLLATE "C" NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),++ webhook_url_ref text COLLATE "C" NOT NULL,++ enabled bigint NOT NULL DEFAULT 1,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ discord_channel_id text COLLATE "C",++ discord_thread_id text COLLATE "C",++ inbound bigint NOT NULL DEFAULT 0,++ UNIQUE (space_id, direction, webhook_url_ref)++);++++CREATE TABLE relay_deliveries (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,++ post_id bigint REFERENCES posts(id) ON DELETE CASCADE,++ mapping_id bigint NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,++ action text COLLATE "C" NOT NULL DEFAULT 'create' CHECK(action IN ('create', 'edit', 'delete')),++ dedupe_key text COLLATE "C" UNIQUE NOT NULL,++ source text COLLATE "C" NOT NULL DEFAULT 'direct' CHECK(source IN ('direct', 'events')),++ event_id text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed', 'dropped', 'skipped')),++ attempts bigint NOT NULL DEFAULT 0,++ next_attempt_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ last_status bigint,++ last_error text COLLATE "C",++ delivered_at text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE TABLE relay_message_map (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ platform text COLLATE "C" NOT NULL DEFAULT 'discord' CHECK(platform IN ('discord')),++ mapping_id bigint NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,++ direction text COLLATE "C" NOT NULL CHECK(direction IN ('out', 'in')),++ local_type text COLLATE "C" NOT NULL CHECK(local_type IN ('thread', 'post')),++ local_id bigint NOT NULL,++ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,++ external_channel_id text COLLATE "C" NOT NULL,++ external_thread_id text COLLATE "C",++ external_message_id text COLLATE "C" NOT NULL,++ external_webhook_id text COLLATE "C",++ external_deleted_at text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ UNIQUE (platform, external_message_id),++ UNIQUE (platform, mapping_id, local_type, local_id)++);++++CREATE TABLE relay_cursors (++ name text COLLATE "C" PRIMARY KEY,++ cursor bigint NOT NULL,++ latest_seq bigint,++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE TABLE relay_inbound_failures (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ mapping_id bigint REFERENCES relay_mappings(id) ON DELETE SET NULL,++ event text COLLATE "C" NOT NULL,++ external_channel_id text COLLATE "C",++ external_message_id text COLLATE "C",++ external_author_id text COLLATE "C",++ error text COLLATE "C" NOT NULL,++ dismissed_at text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++++CREATE TABLE pulse_items (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ source_service text COLLATE "C" NOT NULL,++ source_type text COLLATE "C" NOT NULL,++ source_id text COLLATE "C" NOT NULL,++ title text COLLATE "C" NOT NULL,++ url text COLLATE "C" NOT NULL,++ actor_subject text COLLATE "C",++ origin text COLLATE "C" NOT NULL CHECK(origin IN ('user', 'ai', 'system')),++ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public')),++ occurred_at text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ UNIQUE (source_service, source_type, source_id)++);++++CREATE TABLE game_progress (++ subject_id text COLLATE "C" PRIMARY KEY,++ level bigint NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++++CREATE TABLE import_hold (++ source_type text COLLATE "C" NOT NULL,++ source_id text COLLATE "C" NOT NULL,++ reason text COLLATE "C" NOT NULL,++ detail text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (source_type, source_id, reason)++);++++CREATE TABLE profile_module_pushes (++ subject_id text COLLATE "C" PRIMARY KEY,++ hash text COLLATE "C" NOT NULL,++ pushed_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE TABLE account_data_events (++ id text COLLATE "C" PRIMARY KEY,++ kind text COLLATE "C" NOT NULL,++ subject text COLLATE "C" NOT NULL,++ outcome text COLLATE "C",++ sent_at text COLLATE "C",++ applied_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()++);++++CREATE TABLE network_blocks (++ blocker_subject text COLLATE "C" NOT NULL,++ blocked_subject text COLLATE "C" NOT NULL,++ active bigint NOT NULL,++ revision bigint NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (blocker_subject, blocked_subject)++);++++CREATE INDEX idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL;++CREATE INDEX idx_pastes_owner ON pastes(owner_subject, created_at DESC);++CREATE INDEX idx_pastes_forked ON pastes(forked_from);++CREATE INDEX idx_paste_comments_paste ON paste_comments(paste_id, parent_id);++CREATE INDEX idx_paste_visits_last ON paste_visits(last_at);++CREATE INDEX idx_subject_projection_username ON subject_projection(lower(username));++CREATE INDEX idx_comments_thread ON comments(thread_id, parent_id, id);++CREATE INDEX idx_comments_parent ON comments(parent_id, id);++CREATE INDEX idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL;++CREATE INDEX idx_threads_author ON threads(author_subject, created_at DESC);++CREATE INDEX idx_posts_thread ON posts(thread_id, id);++CREATE INDEX idx_posts_author ON posts(author_subject, created_at DESC);++CREATE INDEX idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;++CREATE INDEX idx_post_reactions_post ON post_reactions(post_id, reaction);++CREATE INDEX idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);++CREATE INDEX idx_relay_message_map_thread ON relay_message_map(thread_id);++CREATE INDEX idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);++CREATE INDEX idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);++CREATE UNIQUE INDEX idx_comment_threads_access ON comment_threads(access_id);++CREATE UNIQUE INDEX idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL;++CREATE INDEX idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL;++CREATE INDEX idx_relay_deliveries_thread ON relay_deliveries(thread_id, post_id);++CREATE INDEX idx_relay_mappings_channel ON relay_mappings(discord_channel_id) WHERE discord_channel_id IS NOT NULL;++CREATE INDEX idx_network_blocks_blocked ON network_blocks(blocked_subject, active);++++-- server/search/documents.js: what was last pushed to OpenVibe.Search per thread and paste++CREATE TABLE search_doc_pushes (++ type text COLLATE "C" NOT NULL,++ id text COLLATE "C" NOT NULL,++ hash text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ deleted bigint NOT NULL DEFAULT 0,++ pushed_at text COLLATE "C" DEFAULT ov_now(),++ PRIMARY KEY (type, id)++);++++-- openvibe-sdk/auth createPgRevocationStore: Network's per-person token cutoffs (revocationSchema('token_revocations'))++CREATE TABLE IF NOT EXISTS token_revocations (++ subject_id text COLLATE "C" PRIMARY KEY,++ valid_after_ms bigint NOT NULL,++ reason text,++ updated_at bigint NOT NULL++);++++-- Rows the SQLite boot seeded++INSERT INTO space_groups (id, slug, name, description, position) OVERRIDING SYSTEM VALUE VALUES++ (1, 'openvibe', 'OpenVibe', 'The network itself: talk, help, requests and what is coming next.', 1),++ (2, 'community', 'Community', 'What the people of OpenVibe make, and everything else.', 2);++SELECT setval(pg_get_serial_sequence('space_groups', 'id'), (SELECT MAX(id) FROM space_groups));++INSERT INTO spaces (id, slug, name, description, visibility, created_by, members_only_owner, thread_kind, style, votes, reactions, group_id, parent_id, position) OVERRIDING SYSTEM VALUE VALUES++ (1, 'general', 'General', 'Anything about OpenVibe and the people on it.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 1, NULL, 1),++ (2, 'feedback', 'Feedback', 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.', 'public', 'system', NULL, 'request', 'feed', 1, 1, 1, NULL, 3),++ (3, 'showcase', 'Showcase', 'Show what you made: streams, clips, art, code, tools.', 'public', 'system', NULL, 'discussion', 'feed', 1, 1, 2, NULL, 1),++ (4, 'roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', NULL, 'roadmap', 'feed', 1, 1, 1, NULL, 4),++ (5, 'help', 'Help', 'Stuck on something? Ask here: streaming, tools, your account, anything on OpenVibe.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 1, NULL, 2),++ (6, 'off-topic', 'Off-topic', 'Anything that is not about OpenVibe.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 2, NULL, 2);++SELECT setval(pg_get_serial_sequence('spaces', 'id'), (SELECT MAX(id) FROM spaces));++INSERT INTO categories (id, space_id, slug, name, description, position) OVERRIDING SYSTEM VALUE VALUES++ (1, 2, 'ideas', 'Ideas', 'Something new, or something better.', 1),++ (2, 2, 'bugs', 'Bugs', 'Something is broken or wrong.', 2),++ (3, 2, 'questions', 'Questions', 'How do I…? Why does…?', 3),++ (4, 4, 'launches', 'New sites', 'Sites that open next.', 1),++ (5, 4, 'features', 'Features', 'New things on sites that are already open.', 2),++ (6, 4, 'platform', 'Under the hood', 'Accounts, safety, reliability and the shared systems every site uses.', 3);++SELECT setval(pg_get_serial_sequence('categories', 'id'), (SELECT MAX(id) FROM categories));++++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled++CREATE TABLE IF NOT EXISTS community_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 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 event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;@@@ -16,15 +16,33 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","highlight.js": "^11.11.1",++ "iovalkey": "^0.4.0","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"},"engines": {"node": ">=20.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",@@@ -243,6 +261,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",@@@ -325,6 +352,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",@@@ -770,6 +806,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",@@@ -834,12 +913,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",@@@ -1057,9 +1148,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.71.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",-- "integrity": "sha512-BLS6gP/MKuwhvTdWO8UYUaOyhmf7ni0cQ8r7150o57R5TrCnzdWQEVESxfMFCGgSbm2wdKtdZwTID69nxD14Tg==",++ "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",@@@ -1073,18 +1164,38 @@}},"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.21.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",++ "integrity": "sha512-4gB+Lsj6Df1ztxI2ZNvPGUzXpSNV3FsEOP2VkcQ1hQIKdUdwH1BFEC16aQIwOrugmhe1U/CSmCWpPVb9bFu1ag==","license": "MIT","engines": {"node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }}},"node_modules/openvibe-shared": {-- "version": "1.25.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",-- "integrity": "sha512-oqZ+PvljrWi8IEbtg6bDYai17ZV2sALFx/ezMptniELSr10TgFUiGUQt+b1IWwiHv0T2Y1U2iSrkirJxMNgjSw==",++ "version": "1.28.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",++ "integrity": "sha512-WLd11lDG75veVi7mFwngquK2nqIQV86Z14Ad/xE2dYvyMT5mhKJxxxlKUoskpdyLeap0ucMvzzS8Tg7vPGwYoQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1105,6 +1216,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",@@@ -1228,6 +1467,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",@@@ -1443,6 +1703,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",@@@ -1578,6 +1853,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"++ }}}}@@@ -8,7 +8,8 @@"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js","test": "node test/run.js",-- "n-1:record": "node scripts/n-1-record.js"++ "n-1:record": "node scripts/n-1-record.js",++ "test:pg": "COMMUNITY_TEST_STORE=pg node test/run.js"},"keywords": ["openvibe",@@@ -26,12 +27,17 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","highlight.js": "^11.11.1",++ "iovalkey": "^0.4.0","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",++ "pg": "^8.23.0"},"engines": {"node": ">=20.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"}}ddeleted file mode 100755@@@ -1,108 +0,0 @@--#!/usr/bin/env node--'use strict';----/**-- * Import OpenVibe.Live's own VOD and clip comments into Community comment threads.-- *-- * node scripts/import-live-comments.js --live-db <live.db> # dry run (default)-- * node scripts/import-live-comments.js --live-db <live.db> --apply --backup <file.db> # write-- *-- * Options:-- * --live-db <file> Live's database, opened read-only (required)-- * --community-db <file> Community's database (default: COMMUNITY_DB_PATH, as the server)-- * --apply write; without it nothing is written and the report shows what would be-- * --backup <file> required with --apply: an online backup of Community's database is written-- * here (must not exist yet) and checked before anything changes-- * --live-links-only name authors from Live's linked_accounts only (no Network call)-- *-- * Reads the same environment as the server (.env / /etc/openvibe/community.env):-- * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the-- * client needs identity.subject.resolve on openvibe.network). Safe to re-run: the ledger-- * (legacy_id_map live/comment) skips rows already imported. See server/comments/live-import.js.-- *-- * Exit codes: 0 done (or dry run finished), 2 refused (bad options, backup failed, reconciliation-- * failed), 1 anything else.-- */--const fs = require('fs');--const path = require('path');----function usage(msg, code = msg ? 2 : 0) {-- if (msg) console.error(msg);-- console.error('usage: node scripts/import-live-comments.js --live-db <live.db> [--community-db <community.db>] [--apply --backup <file>] [--live-links-only]');-- process.exit(code);--}----function parseArgs(argv) {-- const out = { liveDb: null, communityDb: null, apply: false, backup: null, liveLinksOnly: false };-- const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };-- for (let i = 0; i < argv.length; i++) {-- const a = argv[i];-- if (a === '--live-db') out.liveDb = value(++i, a);-- else if (a === '--community-db') out.communityDb = value(++i, a);-- else if (a === '--backup') out.backup = value(++i, a);-- else if (a === '--apply') out.apply = true;-- else if (a === '--dry-run') out.apply = false;-- else if (a === '--live-links-only') out.liveLinksOnly = true;-- else if (a === '-h' || a === '--help') usage();-- else usage(`unknown argument ${a}`);-- }-- if (!out.liveDb) usage('missing --live-db');-- if (out.apply && !out.backup) usage('--apply needs --backup <file>: a backup of Community\'s database is taken first');-- return out;--}----async function main() {-- const args = parseArgs(process.argv.slice(2));-- const Database = require('better-sqlite3');-- const config = require('../server/config');-- const { openDb } = require('../server/db');-- const { importLiveComments, formatReport, LiveImportError } = require('../server/comments/live-import');---- if (!fs.existsSync(args.liveDb)) usage(`no Live database at ${args.liveDb}`);-- const communityPath = args.communityDb || config.dbPath;-- if (!fs.existsSync(communityPath)) usage(`no Community database at ${communityPath}`);-- // Opening runs the additive schema migrations; a dry run must not be what applies them.-- {-- const probe = new Database(communityPath, { readonly: true, fileMustExist: true });-- const cols = new Set(probe.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));-- probe.close();-- if (!cols.has('edited_at')) usage(`Community's database at ${communityPath} predates this importer: deploy and restart Community first`);-- }---- const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });-- const db = openDb(communityPath);-- try {-- if (args.apply) {-- const target = path.resolve(args.backup);-- if (fs.existsSync(target)) usage(`${target} exists already; pick a new backup file`);-- await db.backup(target);-- const check = new Database(target, { readonly: true, fileMustExist: true });-- const ok = check.pragma('integrity_check', { simple: true });-- const counts = check.prepare('SELECT (SELECT COUNT(*) FROM comments) AS comments, (SELECT COUNT(*) FROM comment_threads) AS threads').get();-- check.close();-- if (ok !== 'ok') { console.error(`Refused: the backup at ${target} failed its integrity check (${ok})`); process.exit(2); }-- console.log(`Backup: ${target} (integrity ok; ${counts.comments} comments in ${counts.threads} threads)`);-- }---- let resolveLegacy = null;-- if (!args.liveLinksOnly) {-- const { createNetworkIdentity } = require('../server/identity/network');-- const network = createNetworkIdentity({ config, db });-- // A dry run writes nothing at all, not even the display cache.-- resolveLegacy = (system, ids) => network.resolveLegacy(system, ids, { cache: args.apply });-- }-- const report = await importLiveComments(db, liveDb, {-- resolveLegacy, dryRun: !args.apply, source: path.basename(args.liveDb),-- });-- console.log(formatReport(report));-- if (!args.apply) console.log('\nNothing was written. Re-run with --apply --backup <file> to import.');-- } catch (err) {-- if (err instanceof LiveImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }-- throw err;-- } finally {-- liveDb.close();-- db.close();-- }--}----main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });ddeleted file mode 100644@@@ -1,72 +0,0 @@--#!/usr/bin/env node--'use strict';----/**-- * Import OpenVibe.Media's paste export into Community's database.-- *-- * node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff 2026-08-20T03:00:26Z] [--ambiguous-from 2026-08-17T21:10:29Z]-- *-- * Reads the same environment as the server (.env / /etc/openvibe/community.env):-- * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the-- * client needs identity.subject.resolve). Safe to re-run: see server/pastes/importer.js.-- *-- * Exit codes: 0 imported (or dry run finished), 2 bundle refused, 1 anything else.-- */--const fs = require('fs');--const path = require('path');----function usage(msg) {-- if (msg) console.error(msg);-- console.error('usage: node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff <ISO time>] [--ambiguous-from <ISO time>|none]');-- process.exit(msg ? 1 : 0);--}----function parseArgs(argv) {-- const out = { file: null, dryRun: false, cutoff: null, ambiguousFrom: undefined };-- for (let i = 0; i < argv.length; i++) {-- const a = argv[i];-- if (a === '--dry-run') out.dryRun = true;-- else if (a === '--id-fix-cutoff') { out.cutoff = argv[++i]; if (!out.cutoff) usage('--id-fix-cutoff needs a value'); }-- else if (a.startsWith('--id-fix-cutoff=')) out.cutoff = a.slice('--id-fix-cutoff='.length);-- else if (a === '--ambiguous-from') { const v = argv[++i]; if (!v) usage('--ambiguous-from needs a value'); out.ambiguousFrom = v === 'none' ? null : v; }-- else if (a === '-h' || a === '--help') usage();-- else if (a.startsWith('-')) usage(`unknown option ${a}`);-- else if (!out.file) out.file = a;-- else usage(`unexpected argument ${a}`);-- }-- if (!out.file) usage('missing <bundle.json>');-- return out;--}----async function main() {-- const args = parseArgs(process.argv.slice(2));-- const config = require('../server/config');-- const { openDb } = require('../server/db');-- const { createNetworkIdentity } = require('../server/identity/network');-- const { importBundle, formatReport, ImportError, DEFAULT_CUTOFF } = require('../server/pastes/importer');---- let bundle;-- try { bundle = JSON.parse(fs.readFileSync(args.file, 'utf8')); }-- catch (err) { console.error(`Cannot read ${args.file}: ${err.message}`); process.exit(2); }---- const db = openDb(config.dbPath);-- const network = createNetworkIdentity({ config, db });-- try {-- const report = await importBundle(db, bundle, {-- // A dry run writes nothing at all, not even the display cache.-- resolveLegacy: (system, list) => network.resolveLegacy(system, list, { cache: !args.dryRun }),-- cutoff: args.cutoff || DEFAULT_CUTOFF,-- ...(args.ambiguousFrom !== undefined ? { ambiguousFrom: args.ambiguousFrom } : {}),-- dryRun: args.dryRun,-- source: `${bundle.app || 'media'}:${path.basename(args.file)}`,-- });-- console.log(formatReport(report));-- } catch (err) {-- if (err instanceof ImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }-- throw err;-- } finally {-- db.close();-- }--}----main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });ddeleted file mode 100644@@@ -1,66 +0,0 @@--#!/usr/bin/env node--'use strict';--/**-- * C-24: screenshot pastes stop naming their Media bytes by a legacy reference and name the media object.-- * Pastes imported from Live carry `legacy:live:paste:<Live id>`; Media knows the same screenshot as the-- * object whose legacy_ref is `legacy:live:paste:<slug>`. Pastes uploaded to the v1 file store before the-- * v2 switch carry `legacy:community:file:<key>`, looked up as they are.-- *-- * node scripts/export-legacy-refs.js --prefix legacy:live:paste: --out refs.json # on Media (read-only)-- * node scripts/migrate-screenshot-refs.js --map refs.json # dry run (default)-- * node scripts/migrate-screenshot-refs.js --map refs.json --apply --backup <file.db> # write-- *-- * --map <file> Media's export ({ objects: [{ legacy_ref, id, lifecycle_status }] }); several with commas-- * --apply write; without it nothing changes and the report shows what would-- * --backup <file> required with --apply: an online backup of Community's database, taken first-- * --db <file> Community's database (default: COMMUNITY_DB_PATH / the config)-- * --report <file> write the report as JSON too-- *-- * Only the reference changes (screenshot_url stays, so every page keeps working). Objects Media reports-- * deleted or failed, and pastes with no object, keep their legacy reference and are listed. Idempotent.-- * Exit codes: 0 done (or dry run), 2 refused (bad options, backup failed).-- */--const fs = require('fs');--const path = require('path');----const args = process.argv.slice(2);--const arg = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };--const has = (name) => args.includes(`--${name}`);----async function main() {-- const mapFiles = String(arg('map', '')).split(',').filter(Boolean);-- const apply = has('apply');-- const backup = arg('backup');-- if (!mapFiles.length || (apply && !backup)) { console.error('usage: migrate-screenshot-refs.js --map <refs.json>[,<more>] [--apply --backup <file.db>] [--db <file>] [--report <file>]'); return 2; }-- const byRef = new Map();-- for (const f of mapFiles) for (const o of JSON.parse(fs.readFileSync(f, 'utf8')).objects || []) byRef.set(o.legacy_ref, o);---- const { openDb } = require('../server/db');-- const dbPath = path.resolve(arg('db', process.env.COMMUNITY_DB_PATH || require('../server/config').dbPath));-- const db = openDb(dbPath);-- const rows = db.prepare("SELECT id, slug, media_ref FROM pastes WHERE type = 'screenshot' AND media_ref LIKE 'legacy:%' ORDER BY id").all();-- const report = { db: dbPath, apply, candidates: rows.length, migrated: 0, would_migrate: 0, no_object: [], not_ready: [], changes: [] };-- const plan = [];-- for (const r of rows) {-- const key = /^legacy:live:paste:/.test(r.media_ref) ? `legacy:live:paste:${r.slug}` : r.media_ref;-- const o = byRef.get(key);-- if (!o) { report.no_object.push({ slug: r.slug, media_ref: r.media_ref }); continue; }-- if (!['ready', 'archived'].includes(o.lifecycle_status) || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(o.id)) { report.not_ready.push({ slug: r.slug, media_ref: r.media_ref, object: o.id, status: o.lifecycle_status }); continue; }-- plan.push({ id: r.id, slug: r.slug, from: r.media_ref, to: o.id });-- }-- report.changes = plan.slice(0, 20);-- if (!apply) report.would_migrate = plan.length;-- else {-- try { await db.backup(path.resolve(backup)); } catch (err) { console.error(`[migrate-screenshot-refs] backup failed: ${err.message}`); return 2; }-- const set = db.prepare("UPDATE pastes SET media_ref = ? WHERE id = ? AND media_ref = ?");-- report.migrated = db.transaction(() => plan.reduce((n, p) => n + set.run(p.to, p.id, p.from).changes, 0))();-- report.backup = path.resolve(backup);-- }-- const summary = { ...report, no_object: report.no_object.length, not_ready: report.not_ready.length, changes: undefined };-- console.log(JSON.stringify(summary, null, 1));-- if (arg('report')) fs.writeFileSync(arg('report'), JSON.stringify(report, null, 1));-- db.close();-- return 0;--}----main().then((code) => process.exit(code), (err) => { console.error(err); process.exit(1); });nnew file mode 100755@@@ -0,0 +1,26 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Community's SQLite database (COMMUNITY_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 config = require('../server/config');++const { MIGRATIONS } = require('../server/db');++++const TABLES = {};++++if (require.main === module) {++ runSqliteMigration({ service: 'community', 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 };@@@ -98,7 +98,20 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');await server.close();server = null;-- // 3. The schema N-1 left, and the SQL it runs on it.++ // 3. The schema N-1 left, and the SQL it runs on it. A release on PostgreSQL (ADR-035) records its migration files++ // instead: N must keep every one of them unchanged and only add (the database's own ledger refuses an edit).++ const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };++ const clients = svc.clientReleases ? svc.clientReleases() : null;++ const migDir = path.join(wt.dir, 'migrations');++ if (fs.existsSync(migDir)) {++ const migrations = fs.readdirSync(migDir).filter((f) => f.endsWith('.sql')).sort()++ .map((name) => ({ name, sha256: require('crypto').createHash('sha256').update(fs.readFileSync(path.join(migDir, name))).digest('hex') }));++ fs.mkdirSync(OUT, { recursive: true });++ fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);++ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, engine: 'postgresql', migrations, statements: [] }, null, 1)}\n`);++ console.log(` postgresql: ${migrations.length} migration file(s); wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);++ return;++ }const db = new Database(dbPath);const schema = h.schemaDDL(db);const ledger = {};@@@ -117,8 +130,6 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);fs.mkdirSync(OUT, { recursive: true });-- const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };-- const clients = svc.clientReleases ? svc.clientReleases() : null;fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);@@@ -24,7 +24,7 @@* (/internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall behind;* they also carry token cutoffs and account deletions). Pages keep their per-address form limits.*/--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');const config = require('./config');const FIRST_PARTY = /^svc:/;@@@ -54,7 +54,7 @@ function serviceItself(req) {* limits(name, own) middleware for one app, plus limits.reads(name): the defaults on every GET/HEAD of* a router (its writes set their own limits per route). opts.limits and opts.now are for tests.*/--function createActorLimits({ limits = null, now = () => Date.now(), registry = null, log = console } = {}) {++function createActorLimits({ limits = null, now = () => Date.now(), registry = null, log = console, valkey = null } = {}) {const refused = registry? registry.counter({ name: 'community_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -62,6 +62,8 @@ function createActorLimits({ limits = null, now = () => Date.now(), registry = nlimits: limits || { minute: config.limits.minute, hour: config.limits.hour },actor,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}`);@@@ -41,7 +41,7 @@ const pages = require('./render/pages');const { assetVersion } = require('./render/layout');const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');const { createPastesProxy } = require('./pastes/proxy');--const { openDb, getDb } = require('./db');++const { getDb } = require('./db');const { createNetworkIdentity } = require('./identity/network');const { createViewerResolver } = require('./identity/viewer');const v1 = require('./http/v1');@@@ -67,7 +67,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', 'public');const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;const VERSION = require('../package.json').version;--function createApp(opts = {}) {++async function createApp(opts = {}) {const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -115,11 +115,13 @@ function createApp(opts = {}) {{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }// ── Community's database, identity, and what lives in it in every mode ──-- const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());++ const db = opts.db || getDb();const network = opts.network || createNetworkIdentity({ config, db });// Network's per-person token cutoffs (network.user.token_valid_after): sign out everywhere, password// changes and bans refuse older tokens here at once (WS-B task 4).-- const revocations = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });++ // Cutoffs are read into memory before the app serves: isRevoked() is synchronous on every signed-in request.++ const revocations = require('openvibe-sdk/auth').createPgRevocationStore(db, { table: 'token_revocations' });++ await revocations.load();const viewers = createViewerResolver({ auth, config, network, revocations });const pulse = createPulse({ db, network, config });const relay = opts.relay || createDiscordRelay({@@@ -140,7 +142,7 @@ function createApp(opts = {}) {}),gateway: relayInbound && config.discordRelay.inbound ? createDiscordGateway({token: config.discordRelay.botToken, url: config.discordRelay.gatewayUrl,-- onDispatch: (type, data, ctx) => relayInbound.handle(type, data, ctx),++ onDispatch: async (type, data, ctx) => await relayInbound.handle(type, data, ctx),...(opts.gatewayOptions || {}),}) : null,inbound: relayInbound,@@@ -169,7 +171,7 @@ function createApp(opts = {}) {const media = opts.media || {tokens: files.tokens,async upload({ buffer, filename, mime, owner = null }) {-- if (!mediaObjects.configured) return files.upload({ buffer, filename, mime });++ if (!mediaObjects.configured) return await files.upload({ buffer, filename, mime });const o = await mediaObjects.uploadImage({ buffer, mime, filename, owner, kind: 'screenshot', source: 'community.paste' });return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };},@@@ -193,7 +195,11 @@ function createApp(opts = {}) {// Per-actor limits for every API router below (server/actor-limits.js), after each one resolves its// viewer; the per-address /api/ limit stays in front. opts.actorLimits: { limits, now } (tests).-- const limits = createActorLimits({ registry: metrics.registry, ...(opts.actorLimits || {}) });++ // Valkey (ADR-035): the per-actor limit counters, shared across processes; opts.valkey for tests (null: none).++ const valkey = opts.valkey !== undefined ? opts.valkey++ : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix }) : null);++ app.locals.valkey = valkey;++ const limits = createActorLimits({ registry: metrics.registry, valkey, ...(opts.actorLimits || {}) });// ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));@@@ -227,7 +233,7 @@ function createApp(opts = {}) {release.mount(app, { registry: metrics.registry });// Readiness reports what is actually served: 503 only without the database; Network key,// Live (live mode) and Media (community mode) failures degrade (server/observability.js).-- const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl });++ const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl, valkey });app.get('/api/ready', readiness.handler);// ── Static assets (content-hashed ?v= → immutable) ───────@@@ -307,10 +313,10 @@ function createApp(opts = {}) {// The store is the authority: raw text is served here, the screenshot link is the stored// Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');-- app.get('/p/:slug/raw', withUser, (req, res) => {++ app.get('/p/:slug/raw', withUser, async (req, res) => {if (!SLUG_RE.test(req.params.slug)) return notFound(res);try {-- const out = app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));++ const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));if (out.redirect) return res.redirect(302, out.redirect);res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');res.type('text/plain; charset=utf-8').send(out.content);@@@ -320,9 +326,9 @@ function createApp(opts = {}) {throw err;}});-- app.get('/p/:slug/screenshot', withUser, (req, res) => {++ app.get('/p/:slug/screenshot', withUser, async (req, res) => {if (!SLUG_RE.test(req.params.slug)) return notFound(res);-- try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }++ try { res.redirect(302, await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }catch (err) {if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');if (err.status === 404) return notFound(res);@@@ -41,15 +41,15 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,// Opening a thread writes a row the first time: a person opens at most one a second.router.post('/threads/resolve', serviceCap(WRITE), limits('community.thread.resolve', { minute: 60, hour: 1200 }), resolveLimiter || passAnon, jsonBody,-- run((req) => service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));-- router.get('/threads/:id', read, run((req) => service.get(req.viewer, req.params.id, req.query)));++ run(async (req) => await service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));++ router.get('/threads/:id', read, run(async (req) => await service.get(req.viewer, req.params.id, req.query)));router.post('/threads/:id/comments', serviceCap(WRITE), limits('community.comment.create', { minute: 20, hour: 300 }), anonWriteLimiter || passAnon, jsonBody,-- run((req) => service.add(req.viewer, req.params.id, req.body || {}), 201));-- router.put('/threads/:id/visibility', serviceCap(MOD), moderate, jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));-- router.get('/:commentId', read, run((req) => service.getComment(req.viewer, req.params.commentId)));-- router.patch('/:commentId', serviceCap(WRITE), limits('community.comment.edit', { minute: 30, hour: 300 }), jsonBody, run((req) => service.edit(req.viewer, req.params.commentId, req.body || {})));-- router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), moderate, run((req) => service.remove(req.viewer, req.params.commentId)));-- router.post('/:commentId/votes', serviceCap(WRITE), limits('community.comment.vote', { minute: 120, hour: 1200 }), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));++ run(async (req) => await service.add(req.viewer, req.params.id, req.body || {}), 201));++ router.put('/threads/:id/visibility', serviceCap(MOD), moderate, jsonBody, run(async (req) => await service.setVisibility(req.viewer, req.params.id, req.body || {})));++ router.get('/:commentId', read, run(async (req) => await service.getComment(req.viewer, req.params.commentId)));++ router.patch('/:commentId', serviceCap(WRITE), limits('community.comment.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await service.edit(req.viewer, req.params.commentId, req.body || {})));++ router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), moderate, run(async (req) => await service.remove(req.viewer, req.params.commentId)));++ router.post('/:commentId/votes', serviceCap(WRITE), limits('community.comment.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await service.vote(req.viewer, req.params.commentId, req.body || {})));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;ddeleted file mode 100644@@@ -1,220 +0,0 @@--'use strict';----/**-- * OpenVibe.Live's own VOD and clip comments (live.db `comments`) → Community comment threads.-- *-- * Live used to keep comments on VODs and clips in its own table; they are now the Community-- * threads of EntityRef { service: 'live', type: 'vod'|'clip', id } (Live's /api/comments is an-- * adapter over them). This moves the old rows across, once, keeping their times and text.-- *-- * Every Live row ends up in exactly one bucket, so the report reconciles:-- *-- * read = imported (new this run + already imported by an earlier run) + held + excluded-- *-- * imported the comment is in its thread; legacy_id_map ('live','comment',<live id>) → the-- * Community comment id is the ledger, so a second run imports nothing twice-- * held the author could not be named as a Network subject (unmapped_author), or two-- * sources named different subjects (ambiguous_author), or its parent comment is held-- * (parent_held). Recorded in import_hold ('live_comment', <live id>, reason) and-- * listed; a later run imports them once the mapping exists (and clears the hold)-- * excluded deleted on Live (deleted), a reply under a deleted comment (parent_deleted, Live-- * no longer showed those), a parent that is missing or on other content (orphan),-- * an empty message (empty) or a target type other than vod/clip (bad_target)-- *-- * Authors: Live's linked_accounts(service='network').subject_id (written from verified Network-- * tokens) and the Network's identity map (resolve-batch, system 'live', type 'user'). Either one-- * is enough; when both answer they must agree.-- *-- * Replies nest one level in Community: a Live reply to a reply joins its top-level comment.-- * A row Live marked edited (updated_at later than created_at) keeps that time as edited_at.-- * Live's database is only read. With dryRun (the default) everything runs in a transaction that-- * is rolled back, so the report is exactly what --apply would do.-- */--const store = require('./store');----const SOURCE_TYPE = 'live_comment';--const LEDGER = ['live', 'comment'];--const IMPORTER = 'import:live-comments';----class LiveImportError extends Error {}----function readLive(liveDb) {-- const tables = new Set(liveDb.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));-- if (!tables.has('comments')) throw new LiveImportError('the Live database has no comments table');-- const rows = liveDb.prepare(`SELECT id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at-- FROM comments ORDER BY id`).all();-- const links = new Map();-- if (tables.has('linked_accounts')) {-- const cols = new Set(liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name));-- if (cols.has('subject_id')) {-- for (const r of liveDb.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL").all()) {-- links.set(String(r.user_id), r.subject_id);-- }-- }-- }-- return { rows, links };--}----/** Live's SQLite times are UTC 'YYYY-MM-DD HH:MM:SS'; keep them in that form. */--function sqlTime(v) {-- if (!v) return null;-- const s = String(v);-- if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s;-- const d = new Date(/^\d{4}-\d{2}-\d{2}T/.test(s) || /Z$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);-- return Number.isNaN(d.getTime()) ? null : d.toISOString().replace('T', ' ').slice(0, 19);--}----const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;----/**-- * opts.resolveLegacy(system, ids) → Map(id → { subject: { type, id } } | null) (Network), or null-- * to rely on Live's own links only. opts.dryRun (default true). opts.source names the run.-- */--async function importLiveComments(db, liveDb, { resolveLegacy = null, dryRun = true, source = 'live.db' } = {}) {-- const { rows, links } = readLive(liveDb);-- const byId = new Map(rows.map((r) => [r.id, r]));---- // ── Classify: excluded rows first (they need no author) ──-- const excluded = new Map(); // live id → reason-- const rootOf = new Map(); // live id → top-level live id-- for (const r of rows) {-- if (r.is_deleted) { excluded.set(r.id, 'deleted'); continue; }-- if (!['vod', 'clip'].includes(r.content_type) || !(Number(r.content_id) > 0)) { excluded.set(r.id, 'bad_target'); continue; }-- if (!String(r.message || '').trim()) { excluded.set(r.id, 'empty'); continue; }-- let cur = r, reason = null;-- const seen = new Set([r.id]);-- while (cur.parent_id != null) {-- const p = byId.get(cur.parent_id);-- if (!p || seen.has(p.id) || p.content_type !== r.content_type || p.content_id !== r.content_id) { reason = 'orphan'; break; }-- if (p.is_deleted) { reason = 'parent_deleted'; break; }-- seen.add(p.id);-- cur = p;-- }-- if (reason) excluded.set(r.id, reason);-- else rootOf.set(r.id, cur.id);-- }---- // ── Authors → subjects ──-- const userIds = [...new Set(rows.filter((r) => rootOf.has(r.id)).map((r) => String(r.user_id)))];-- let network = new Map();-- if (resolveLegacy && userIds.length) network = await resolveLegacy('live', userIds);-- const subjectFor = new Map(); // live user id → { subject } | { reason, detail }-- for (const uid of userIds) {-- const hit = network.get(uid);-- const fromNet = hit && hit.subject && hit.subject.type === 'user' && USER_SUBJECT.test(hit.subject.id) ? hit.subject.id : null;-- const fromLive = USER_SUBJECT.test(String(links.get(uid) || '')) ? links.get(uid) : null;-- if (fromNet && fromLive && fromNet !== fromLive) subjectFor.set(uid, { reason: 'ambiguous_author', detail: `network says ${fromNet}, Live's link says ${fromLive}` });-- else if (fromNet || fromLive) subjectFor.set(uid, { subject: fromNet || fromLive });-- else subjectFor.set(uid, { reason: 'unmapped_author', detail: `live user ${uid} has no Network subject` });-- }---- const report = {-- source, dry_run: !!dryRun, read: rows.length,-- imported: 0, imported_new: 0, imported_before: 0, held: 0, excluded: 0,-- excluded_by_reason: {}, held_rows: [], threads: [], reconciled: false,-- };---- const ledger = db.prepare('SELECT target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?');-- const ledgerGet = (liveId) => {-- const hit = ledger.get(LEDGER[0], LEDGER[1], String(liveId));-- return hit ? Number(hit.target_id) : null;-- };-- const ledgerSet = db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, 'comment', ?)-- ON CONFLICT(source_system, source_type, source_id) DO NOTHING`);-- const hold = db.prepare(`INSERT INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)-- ON CONFLICT(source_type, source_id, reason) DO UPDATE SET detail = excluded.detail`);-- const unhold = db.prepare('DELETE FROM import_hold WHERE source_type = ? AND source_id = ?');-- const insert = db.prepare(`INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, edited_at)-- VALUES (?, ?, ?, NULL, 'user', ?, ?, ?, ?)`);---- const apply = () => {-- const touchedThreads = new Set();-- const touchedParents = new Set();-- const threadIds = new Map(); // 'vod:12' → thread row-- const threadFor = (r) => {-- const key = `${r.content_type}:${r.content_id}`;-- if (!threadIds.has(key)) {-- const { thread } = store.resolveThread(db, { service: 'live', type: r.content_type, id: String(r.content_id) }, { createdBy: IMPORTER });-- threadIds.set(key, thread);-- }-- return threadIds.get(key);-- };-- const state = new Map(); // live id → 'imported' | 'held'---- // Top-level comments first, then replies (whose parent must be placed already).-- const ordered = rows.filter((r) => rootOf.has(r.id)).sort((a, b) => ((a.parent_id == null ? 0 : 1) - (b.parent_id == null ? 0 : 1)) || (a.id - b.id));-- for (const r of ordered) {-- const root = rootOf.get(r.id);-- const who = subjectFor.get(String(r.user_id));-- let heldReason = who.subject ? null : who.reason;-- let detail = who.subject ? null : who.detail;-- if (!heldReason && root !== r.id && state.get(root) === 'held') { heldReason = 'parent_held'; detail = `parent comment ${root} is held`; }-- if (heldReason) {-- state.set(r.id, 'held');-- unhold.run(SOURCE_TYPE, String(r.id)); // one current reason per row-- hold.run(SOURCE_TYPE, String(r.id), heldReason, JSON.stringify({ detail, content_type: r.content_type, content_id: r.content_id, user_id: r.user_id }));-- report.held_rows.push({ live_id: r.id, reason: heldReason, content: `${r.content_type}/${r.content_id}`, live_user_id: r.user_id, detail });-- continue;-- }-- const thread = threadFor(r);-- if (!report.threads.includes(thread.access_id)) report.threads.push(thread.access_id);-- const known = ledgerGet(r.id);-- if (known) { state.set(r.id, 'imported'); report.imported_before++; continue; }-- const parentCommunityId = root === r.id ? null : ledgerGet(root);-- const created = sqlTime(r.created_at) || sqlTime(new Date().toISOString());-- const updated = sqlTime(r.updated_at) || created;-- const edited = updated > created ? updated : null;-- const info = insert.run(thread.id, parentCommunityId, who.subject, String(r.message).trim(), created, updated, edited);-- ledgerSet.run(LEDGER[0], LEDGER[1], String(r.id), String(info.lastInsertRowid));-- unhold.run(SOURCE_TYPE, String(r.id));-- state.set(r.id, 'imported');-- report.imported_new++;-- touchedThreads.add(thread.id);-- if (parentCommunityId) touchedParents.add(parentCommunityId);-- }---- // Counters from the rows (the store keeps them in step for live writes; here we set them once).-- for (const id of touchedThreads) {-- db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id, id);-- }-- for (const id of touchedParents) {-- db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(id, id);-- }---- report.imported = report.imported_new + report.imported_before;-- report.held = report.held_rows.length;-- for (const reason of excluded.values()) report.excluded_by_reason[reason] = (report.excluded_by_reason[reason] || 0) + 1;-- report.excluded = excluded.size;-- report.reconciled = report.read === report.imported + report.held + report.excluded;-- if (!report.reconciled) throw new LiveImportError(`reconciliation failed: read ${report.read} ≠ imported ${report.imported} + held ${report.held} + excluded ${report.excluded}`);-- if (!dryRun) {-- db.prepare('INSERT INTO migration_runs (source, finished_at, report) VALUES (?, CURRENT_TIMESTAMP, ?)').run(`live-comments:${source}`, JSON.stringify(report));-- }-- };---- const ROLLBACK = Symbol('dry run');-- try {-- db.transaction(() => { apply(); if (dryRun) throw ROLLBACK; })();-- } catch (err) {-- if (err !== ROLLBACK) throw err;-- }-- return report;--}----function formatReport(r) {-- const lines = [];-- lines.push(`Live comments → Community threads (${r.dry_run ? 'DRY RUN — nothing was written' : 'APPLIED'}) from ${r.source}`);-- lines.push(` read ${r.read}`);-- lines.push(` imported ${r.imported} (new ${r.imported_new}, already imported ${r.imported_before})`);-- lines.push(` held ${r.held}`);-- lines.push(` excluded ${r.excluded}${Object.keys(r.excluded_by_reason).length ? ` (${Object.entries(r.excluded_by_reason).map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}`);-- lines.push(` check read ${r.read} = imported ${r.imported} + held ${r.held} + excluded ${r.excluded} → ${r.reconciled ? 'OK' : 'MISMATCH'}`);-- lines.push(` threads ${r.threads.length}`);-- if (r.held_rows.length) {-- lines.push(' held rows (not imported; re-run once the author has a Network subject):');-- for (const h of r.held_rows) lines.push(` live comment ${h.live_id} ${h.reason} on ${h.content} live user ${h.live_user_id}${h.detail ? ` — ${h.detail}` : ''}`);-- }-- return lines.join('\n');--}----module.exports = { importLiveComments, formatReport, LiveImportError, SOURCE_TYPE };@@@ -115,15 +115,15 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsconst all = [];for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }const projections = await authors.projectionsFor(all.map((c) => c.author_subject));-- const votes = myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);++ const votes = await myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v)));}/** A thread by the id this viewer may use: the access id for anyone, the sequential id for services only. */-- function threadById(v, id) {++ async function threadById(v, id) {const s = String(id == null ? '' : id);-- if (ACCESS_ID_RE.test(s)) return store.getThreadByAccessId(db, s);-- if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return store.getThread(db, Number(s));++ if (ACCESS_ID_RE.test(s)) return await store.getThreadByAccessId(db, s);++ if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return await store.getThread(db, Number(s));return null;}@@@ -132,23 +132,23 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsif (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');return t;}-- const visibleThread = (v, id) => visible(v, threadById(v, id));++ const visibleThread = async (v, id) => visible(v, await threadById(v, id));-- function visibleComment(v, id) {-- const c = /^\d{1,15}$/.test(String(id)) ? store.getComment(db, Number(id)) : null;++ async function visibleComment(v, id) {++ const c = /^\d{1,15}$/.test(String(id)) ? await store.getComment(db, Number(id)) : null;if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');-- return { comment: c, thread: visible(v, store.getThread(db, c.thread_id)) };++ return { comment: c, thread: visible(v, await store.getThread(db, c.thread_id)) };}/** Community's own entities must exist (and be visible) before anyone opens a thread on them. */-- function checkCommunityRef(ref) {++ async function checkCommunityRef(ref) {if (ref.service !== 'community') return;if (ref.type === 'paste') {if (!pastesLocal) return; // PASTES_AUTHORITY=live: Live owns them, nothing to check here-- const p = pasteStore.getBySlug(db, ref.id);++ const p = await pasteStore.getBySlug(db, ref.id);if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');} else if (ref.type === 'post') {-- const ok = /^\d{1,15}$/.test(ref.id) && db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id++ const ok = /^\d{1,15}$/.test(ref.id) && await db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_idWHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'`).get(Number(ref.id));if (!ok) fail(404, 'ref.not_found', 'No such post');}@@@ -157,10 +157,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsconst signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);/** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */-- function entityOwner(t) {++ async function entityOwner(t) {if (t.ref_service !== 'community') return null;-- if (t.ref_type === 'paste' && pastesLocal) { const p = pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }-- if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }++ if (t.ref_type === 'paste' && pastesLocal) { const p = await pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }++ if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = await db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }return null;}@@@ -182,7 +182,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsSIGNED_IN_ONLY,/** POST /threads/resolve { ref } → { thread, created } */-- resolve(v, body = {}) {++ async resolve(v, body = {}) {const ref = body && body.ref;const check = ref && typeof ref === 'object' ? contracts.validate('common.entity-ref@1', ref) : { valid: false };if (!check.valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');@@@ -190,10 +190,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsconst types = BROWSER_REF_TYPES[ref.service];if (!types || !types.includes(ref.type)) fail(403, 'ref.type_not_allowed', `Comments on ${ref.service}/${ref.type} can only be opened by that service`);}-- checkCommunityRef(ref);++ await checkCommunityRef(ref);// Labels are display caches; only a service's word is taken for them.const label = v.kind === 'service' && ref.label ? String(ref.label).slice(0, 200) : null;-- const { thread, created } = store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });++ const { thread, created } = await store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });return { thread: shapeThread(thread, v), created };},@@@ -203,17 +203,17 @@ function createCommentService({ db, network = null, pastesLocal = false, limits* comment's replies instead.*/async get(v, id, q = {}) {-- const t = visibleThread(v, id);++ const t = await visibleThread(v, id);const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);const after = cursorId(q.after);let rows, hasMore;if (q.parent != null && q.parent !== '') {const parent = cursorId(q.parent);-- const p = parent != null ? store.getComment(db, parent) : null;++ const p = parent != null ? await store.getComment(db, parent) : null;if (!p || p.thread_id !== t.id || p.parent_id) fail(404, 'comment.not_found', 'Comment not found');-- ({ rows, hasMore } = store.listReplies(db, parent, { after, limit }));++ ({ rows, hasMore } = await store.listReplies(db, parent, { after, limit }));} else {-- ({ rows, hasMore } = store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));++ ({ rows, hasMore } = await store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));}const comments = await shapeComments(rows, v, t);return {@@@ -231,14 +231,14 @@ function createCommentService({ db, network = null, pastesLocal = false, limits/** POST /threads/:id/comments { message, parent_id?, anon_name? } */async add(v, id, body = {}) {-- const t = visibleThread(v, id);++ const t = await visibleThread(v, id);if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');const message = cleanMessage(body.message);let parent = null;let parentId = null;if (body.parent_id != null && body.parent_id !== '') {-- parent = /^\d{1,15}$/.test(String(body.parent_id)) ? store.getComment(db, Number(body.parent_id)) : null;++ parent = /^\d{1,15}$/.test(String(body.parent_id)) ? await store.getComment(db, Number(body.parent_id)) : null;if (!parent || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'comment.invalid_parent', 'Invalid parent comment');// One level of nesting: a reply to a reply joins the top-level comment's replies.parentId = parent.parent_id || parent.id;@@@ -249,10 +249,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits// Platform blocks: no reply to a comment whose author blocked you (the one answered, and the// top-level comment it joins), no comment on a paste or post whose owner blocked you.if (parent) {-- const top = parent.parent_id ? store.getComment(db, parent.parent_id) : null;-- blocks.refuseIfBlocked(db, [parent.author_subject, top && top.author_subject], author, 'reply to this comment');++ const top = parent.parent_id ? await store.getComment(db, parent.parent_id) : null;++ await blocks.refuseIfBlocked(db, [parent.author_subject, top && top.author_subject], author, 'reply to this comment');}-- blocks.refuseIfBlocked(db, [entityOwner(t)], author, `comment on this ${t.ref_type}`, 'its owner');++ await blocks.refuseIfBlocked(db, [await entityOwner(t)], author, `comment on this ${t.ref_type}`, 'its owner');if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');let anonName = null;if (!author && origin !== 'ai') {@@@ -260,7 +260,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits}const key = author ? `s:${author}` : null;commentLimiter.check(key, message);-- const c = store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });++ const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });commentLimiter.record(key, message);return { comment: (await shapeComments([c], v, t))[0] };},@@@ -271,52 +271,52 @@ function createCommentService({ db, network = null, pastesLocal = false, limits*/async getComment(v, commentId) {if (!v || v.kind !== 'service') fail(404, 'comment.not_found', 'Comment not found');-- const { comment: c, thread: t } = visibleComment(v, commentId);++ const { comment: c, thread: t } = await visibleComment(v, commentId);return { comment: (await shapeComments([c], v, t))[0], thread: shapeThread(t, v) };},/** PATCH /:commentId { message } — the author only. */async edit(v, commentId, body = {}) {if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to edit a comment');-- const { comment: c, thread: t } = visibleComment(v, commentId);++ const { comment: c, thread: t } = await visibleComment(v, commentId);if (!(c.author_subject && c.author_subject === v.subject)) fail(403, 'comment.not_yours', 'Only the author can edit a comment');if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');const message = cleanMessage(body.message);-- const row = message === c.message ? c : store.editComment(db, c.id, message);++ const row = message === c.message ? c : await store.editComment(db, c.id, message);return { comment: (await shapeComments([row], v, t))[0] };},/** DELETE /comments/:id — the author or a moderator. */-- remove(v, commentId) {-- const { comment: c } = visibleComment(v, commentId);++ async remove(v, commentId) {++ const { comment: c } = await visibleComment(v, commentId);if (!(v && v.subject) && !moderator(v)) fail(401, 'auth.required', 'Sign in to delete a comment');const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');-- store.softDeleteComment(db, c.id, v.subject || v.service || null);++ await store.softDeleteComment(db, c.id, v.subject || v.service || null);return { ok: true, id: c.id };},/** POST /comments/:id/votes { value: 1 | -1 | 0 } */-- vote(v, commentId, body = {}) {++ async vote(v, commentId, body = {}) {const value = parseVote(body.value);if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to vote');-- const { comment: c, thread: t } = visibleComment(v, commentId);++ const { comment: c, thread: t } = await visibleComment(v, commentId);if (t.visibility !== 'public') fail(403, 'thread.locked', 'This comment thread is locked');voteLimiter.check(`s:${v.subject}`);-- const out = applyVote(db, 'comment', c.id, v.subject, value);++ const out = await applyVote(db, 'comment', c.id, v.subject, value);voteLimiter.record(`s:${v.subject}`);return { comment_id: c.id, ...out };},/** PUT /threads/:id/visibility { visibility } — moderators. */-- setVisibility(v, id, body = {}) {++ async setVisibility(v, id, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');const visibility = body.visibility;if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);-- const t = threadById(v, id);++ const t = await threadById(v, id);if (!t) fail(404, 'thread.not_found', 'Comment thread not found');-- return { thread: shapeThread(store.setThreadVisibility(db, t.id, visibility), v) };++ return { thread: shapeThread(await store.setThreadVisibility(db, t.id, visibility), v) };},};}@@@ -11,17 +11,17 @@*/const { newThreadAccessId } = require('../db');--function getThread(db, id) {-- return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;++async function getThread(db, id) {++ return await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;}/** A thread by the unguessable handle browsers use (server/db.js: access_id). */--function getThreadByAccessId(db, accessId) {-- return db.prepare('SELECT * FROM comment_threads WHERE access_id = ?').get(String(accessId)) || null;++async function getThreadByAccessId(db, accessId) {++ return await db.prepare('SELECT * FROM comment_threads WHERE access_id = ?').get(String(accessId)) || null;}--function getThreadByRef(db, ref) {-- return db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?')++async function getThreadByRef(db, ref) {++ return await db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?').get(ref.service, ref.type, String(ref.id)) || null;}@@@ -30,26 +30,26 @@ function getThreadByRef(db, ref) {* constraint decides, INSERT … ON CONFLICT DO NOTHING never fails, and the row is read back.* A label (from a service) refreshes the cached one. → { thread, created }*/--function resolveThread(db, ref, { label = null, createdBy = null } = {}) {-- return db.transaction(() => {-- const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?)-- ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING`)++async function resolveThread(db, ref, { label = null, createdBy = null } = {}) {++ return await db.tx(async () => {++ const info = await db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?)++ ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING RETURNING id`).run(ref.service, ref.type, String(ref.id), label, createdBy, newThreadAccessId());if (!info.changes && label) {-- db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?')++ await db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = ov_now() WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?').run(label, ref.service, ref.type, String(ref.id), label);}-- return { thread: getThreadByRef(db, ref), created: info.changes > 0 };-- })();++ return { thread: await getThreadByRef(db, ref), created: info.changes > 0 };++ });}--function setThreadVisibility(db, id, visibility) {-- db.prepare('UPDATE comment_threads SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(visibility, id);-- return getThread(db, id);++async function setThreadVisibility(db, id, visibility) {++ await db.prepare('UPDATE comment_threads SET visibility = ?, updated_at = ov_now() WHERE id = ?').run(visibility, id);++ return await getThread(db, id);}--function getComment(db, id) {-- return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;++async function getComment(db, id) {++ return await db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;}/**@@@ -57,13 +57,13 @@ function getComment(db, id) {* not deleted and does not burn, or a live post in a public space outside members-only. Anything else (a* Live VOD or clip may be private; only Live knows) is not, so its comment events stay internal.*/--function refIsPublic(db, t) {++async function refIsPublic(db, t) {if (!t || t.ref_service !== 'community') return false;if (t.ref_type === 'paste') {-- return !!db.prepare("SELECT 1 FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0").get(String(t.ref_id));++ return !!await db.prepare("SELECT 1 FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0").get(String(t.ref_id));}if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) {-- return !!db.prepare(`SELECT 1 FROM posts p JOIN threads th ON th.id = p.thread_id JOIN spaces s ON s.id = th.space_id++ return !!await db.prepare(`SELECT 1 FROM posts p JOIN threads th ON th.id = p.thread_id JOIN spaces s ON s.id = th.space_idWHERE p.id = ? AND p.deleted_at IS NULL AND th.deleted_at IS NULL AND s.visibility = 'public'AND s.members_only_owner IS NULL AND th.members_only_owner IS NULL`).get(Number(t.ref_id));}@@@ -71,35 +71,35 @@ function refIsPublic(db, t) {}/** Insert a comment and keep the thread's and parent's counters in step. */--function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {-- return db.transaction(() => {-- const info = db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?)')++async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {++ return await db.tx(async () => {++ const info = await db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?) RETURNING id').run(thread_id, parent_id, author_subject, anon_name, origin, message);-- db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);-- if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);-- const comment = getComment(db, info.lastInsertRowid);-- const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);-- if (cthread) require('../events').commentCreated(comment, cthread, { itemPublic: refIsPublic(db, cthread) }); // community.comment.created++ await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);++ if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);++ const comment = await getComment(db, info.lastInsertRowid);++ const cthread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);++ if (cthread) await require('../events').commentCreated(comment, cthread, { itemPublic: await refIsPublic(db, cthread) }); // community.comment.createdreturn comment;-- })();++ });}/** The author's new text; edited_at records that it changed. → the row */--function editComment(db, id, message) {-- db.prepare('UPDATE comments SET message = ?, edited_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(message, id);-- return getComment(db, id);++async function editComment(db, id, message) {++ await db.prepare('UPDATE comments SET message = ?, edited_at = ov_now(), updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(message, id);++ return await getComment(db, id);}/** Soft delete; counters recomputed from the rows. → changes */--function softDeleteComment(db, id, deletedBy = null) {-- return db.transaction(() => {-- const c = getComment(db, id);++async function softDeleteComment(db, id, deletedBy = null) {++ return await db.tx(async () => {++ const c = await getComment(db, id);if (!c || c.deleted_at) return 0;-- db.prepare("UPDATE comments SET deleted_at = CURRENT_TIMESTAMP, deleted_by = ?, message = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(deletedBy, id);-- db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(c.thread_id, c.thread_id);-- if (c.parent_id) db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);++ await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);++ await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);++ if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);return 1;-- })();++ });}/**@@@ -109,23 +109,23 @@ function softDeleteComment(db, id, deletedBy = null) {* Deleted top-level comments appear only while they still have replies (tombstones).* → { rows, hasMore } — each row carries .replies (oldest first) and .reply_count*/--function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {++async function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {const newest = sort === 'new';const params = [threadId];let cursor = '';if (after != null) { cursor = newest ? 'AND id < ?' : 'AND id > ?'; params.push(after); }-- const rows = db.prepare(`SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ${cursor}++ const rows = await db.prepare(`SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ${cursor}ORDER BY id ${newest ? 'DESC' : 'ASC'} LIMIT ?`).all(...params, limit + 1);const hasMore = rows.length > limit;if (hasMore) rows.pop();const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');-- for (const r of rows) r.replies = replies.all(r.id, replyLimit);++ for (const r of rows) r.replies = await replies.all(r.id, replyLimit);return { rows, hasMore };}/** Replies of one comment after a cursor (for comments with more replies than the first page shows). */--function listReplies(db, parentId, { after = null, limit = 50 } = {}) {-- const rows = db.prepare(`SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ${after != null ? 'AND id > ?' : ''} ORDER BY id ASC LIMIT ?`)++async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {++ const rows = await db.prepare(`SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ${after != null ? 'AND id > ?' : ''} ORDER BY id ASC LIMIT ?`).all(...(after != null ? [parentId, after] : [parentId]), limit + 1);const hasMore = rows.length > limit;if (hasMore) rows.pop();@@@ -60,8 +60,13 @@ module.exports = {// 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.// 'community' — this site's own database is the authority (server/pastes/api.js).pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',-- // SQLite file for Community's own data: comments, the forum, Pulse, the relay's bookkeeping,-- // and pastes once authority=community.++ // PostgreSQL (ADR-035) for Community's own data: comments, the forum, Pulse, the relay's bookkeeping, and pastes++ // once authority=community. DATABASE_URL serves (PgBouncer); DATABASE_DIRECT_URL migrates (owner). Without them,++ // development uses an embedded PGlite database in data/pglite.++ db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },++ // Valkey (ADR-035): per-actor limit counters shared across processes; without it they count in this process.++ valkey: { url: process.env.VALKEY_URL || '', prefix: process.env.VALKEY_PREFIX || 'ov:community:' },++ // The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.dbPath: process.env.COMMUNITY_DB_PATH || './data/community.db',// Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)@@@ -1,7 +1,7 @@'use strict';/**-- * Community's own database (SQLite, better-sqlite3).++ * Community's own database: PostgreSQL (ADR-035, roadmap WS-X2). The schema is migrations/NNNN_*.sql, applied at boot.** Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit* history, likes and comments live here. Typed comment threads, the forum (spaces, threads,@@@ -9,581 +9,59 @@* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache* of what the Network says about them.*-- * Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.++ * Timestamps stay SQLite's text ('YYYY-MM-DD HH:MM:SS', UTC): the migration defines ov_now(), datetime() and++ * julianday() with SQLite's behaviour, and ov_hot() for the forum's hot rank.*/const crypto = require('crypto');const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++const { createDb } = require('openvibe-sdk/db');--// relay_deliveries' columns: the table in SCHEMA, and migrate()'s one-time rebuild of the older shape.--const RELAY_DELIVERIES_COLUMNS = `-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,-- post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,-- mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,-- action TEXT NOT NULL DEFAULT 'create' CHECK(action IN ('create', 'edit', 'delete')),-- dedupe_key TEXT UNIQUE NOT NULL,-- source TEXT NOT NULL DEFAULT 'direct' CHECK(source IN ('direct', 'events')),-- event_id TEXT,-- status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed', 'dropped', 'skipped')),-- attempts INTEGER NOT NULL DEFAULT 0,-- next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- last_status INTEGER,-- last_error TEXT,-- delivered_at DATETIME,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP--`;----const SCHEMA = `--CREATE TABLE IF NOT EXISTS pastes (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- slug TEXT UNIQUE NOT NULL,-- owner_subject TEXT,-- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),-- type TEXT NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),-- title TEXT NOT NULL DEFAULT 'Untitled',-- content TEXT,-- language TEXT DEFAULT 'text',-- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),-- screenshot_url TEXT,-- media_ref TEXT,-- stream_ref TEXT, -- JSON EntityRef, e.g. {"service":"live","type":"stream","id":"123"}-- metadata TEXT, -- JSON-- burn_after_read INTEGER NOT NULL DEFAULT 0,-- forked_from INTEGER REFERENCES pastes(id) ON DELETE SET NULL,-- pinned INTEGER NOT NULL DEFAULT 0,-- views INTEGER NOT NULL DEFAULT 0,-- unique_views INTEGER NOT NULL DEFAULT 0,-- copies INTEGER NOT NULL DEFAULT 0,-- likes INTEGER NOT NULL DEFAULT 0,-- is_nsfw INTEGER NOT NULL DEFAULT 0,-- ai_summary TEXT,-- ai_tags TEXT, -- JSON array-- ai_analyzed_at DATETIME,-- revision INTEGER NOT NULL DEFAULT 1,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- deleted_at DATETIME,-- legacy_media_id INTEGER UNIQUE,-- legacy_user_id INTEGER--);--CREATE INDEX IF NOT EXISTS idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL;--CREATE INDEX IF NOT EXISTS idx_pastes_owner ON pastes(owner_subject, created_at DESC);--CREATE INDEX IF NOT EXISTS idx_pastes_forked ON pastes(forked_from);----CREATE TABLE IF NOT EXISTS paste_versions (-- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,-- revision INTEGER NOT NULL,-- title TEXT,-- content TEXT,-- language TEXT,-- edited_by TEXT,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (paste_id, revision)--);----CREATE TABLE IF NOT EXISTS paste_likes (-- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,-- subject_id TEXT NOT NULL,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (paste_id, subject_id)--);----CREATE TABLE IF NOT EXISTS paste_comments (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,-- author_subject TEXT,-- anon_name TEXT,-- parent_id INTEGER REFERENCES paste_comments(id) ON DELETE CASCADE,-- message TEXT NOT NULL,-- is_deleted INTEGER NOT NULL DEFAULT 0,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- legacy_media_id INTEGER UNIQUE--);--CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id, parent_id);----CREATE TABLE IF NOT EXISTS paste_visits (-- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,-- visitor TEXT NOT NULL,-- first_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- last_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- visits INTEGER NOT NULL DEFAULT 1,-- PRIMARY KEY (paste_id, visitor)--);--CREATE INDEX IF NOT EXISTS idx_paste_visits_last ON paste_visits(last_at);----CREATE TABLE IF NOT EXISTS subject_projection (-- subject_id TEXT PRIMARY KEY,-- username TEXT,-- display_name TEXT,-- avatar_url TEXT,-- profile_color TEXT,-- refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP--);--CREATE INDEX IF NOT EXISTS idx_subject_projection_username ON subject_projection(username COLLATE NOCASE);----CREATE TABLE IF NOT EXISTS migration_runs (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- source TEXT NOT NULL,-- started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- finished_at DATETIME,-- report TEXT--);----CREATE TABLE IF NOT EXISTS legacy_id_map (-- source_system TEXT NOT NULL,-- source_type TEXT NOT NULL,-- source_id TEXT NOT NULL,-- target_type TEXT NOT NULL,-- target_id TEXT NOT NULL,-- PRIMARY KEY (source_system, source_type, source_id)--);----CREATE TABLE IF NOT EXISTS comment_threads (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- ref_service TEXT NOT NULL,-- ref_type TEXT NOT NULL,-- ref_id TEXT NOT NULL,-- ref_label TEXT, -- cached display label from a service; never authoritative-- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')),-- comment_count INTEGER NOT NULL DEFAULT 0,-- created_by TEXT, -- subject or service principal that first resolved it-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- access_id TEXT, -- unguessable handle for browsers (migrate() fills old rows, unique index)-- UNIQUE (ref_service, ref_type, ref_id)--);----CREATE TABLE IF NOT EXISTS comments (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- thread_id INTEGER NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,-- parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE,-- author_subject TEXT,-- anon_name TEXT,-- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai')),-- message TEXT NOT NULL,-- score INTEGER NOT NULL DEFAULT 0,-- upvotes INTEGER NOT NULL DEFAULT 0,-- downvotes INTEGER NOT NULL DEFAULT 0,-- reply_count INTEGER NOT NULL DEFAULT 0,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- deleted_at DATETIME,-- deleted_by TEXT,-- edited_at DATETIME -- the author last changed the text (migrate() adds it to older databases)--);--CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id, parent_id, id);--CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id, id);----CREATE TABLE IF NOT EXISTS comment_votes (-- comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,-- subject_id TEXT NOT NULL,-- value INTEGER NOT NULL CHECK(value IN (-1, 1)),-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (comment_id, subject_id)--);----CREATE TABLE IF NOT EXISTS spaces (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- slug TEXT UNIQUE NOT NULL,-- name TEXT NOT NULL,-- description TEXT,-- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),-- created_by TEXT,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP--);--INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by) VALUES-- ('general', 'General', 'Anything about OpenVibe and the people on it.', 'public', 'system'),-- ('feedback', 'Feedback', 'Feature requests, bugs and ideas for every OpenVibe site.', 'public', 'system'),-- ('showcase', 'Showcase', 'Show what you made: streams, clips, art, code, tools.', 'public', 'system');----CREATE TABLE IF NOT EXISTS threads (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,-- slug TEXT NOT NULL,-- title TEXT NOT NULL,-- author_subject TEXT,-- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),-- pinned INTEGER NOT NULL DEFAULT 0,-- locked INTEGER NOT NULL DEFAULT 0,-- score INTEGER NOT NULL DEFAULT 0,-- reply_count INTEGER NOT NULL DEFAULT 0,-- last_activity_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- deleted_at DATETIME,-- UNIQUE (space_id, slug)--);--CREATE INDEX IF NOT EXISTS idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL;--CREATE INDEX IF NOT EXISTS idx_threads_author ON threads(author_subject, created_at DESC);----CREATE TABLE IF NOT EXISTS posts (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,-- author_subject TEXT,-- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),-- is_opening INTEGER NOT NULL DEFAULT 0,-- body_markdown TEXT NOT NULL,-- revision INTEGER NOT NULL DEFAULT 1,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- deleted_at DATETIME--);--CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, id);--CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_subject, created_at DESC);----CREATE TABLE IF NOT EXISTS post_versions (-- post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,-- revision INTEGER NOT NULL,-- body_markdown TEXT NOT NULL,-- edited_by TEXT,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (post_id, revision)--);----CREATE TABLE IF NOT EXISTS thread_votes (-- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,-- subject_id TEXT NOT NULL,-- value INTEGER NOT NULL CHECK(value IN (-1, 1)),-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (thread_id, subject_id)--);----CREATE TABLE IF NOT EXISTS attachments (-- media_id TEXT PRIMARY KEY,-- owner_subject TEXT NOT NULL,-- post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,-- position INTEGER NOT NULL DEFAULT 0,-- filename TEXT,-- mime TEXT NOT NULL,-- size_bytes INTEGER NOT NULL,-- url TEXT NOT NULL,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP--);--CREATE INDEX IF NOT EXISTS idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;----CREATE TABLE IF NOT EXISTS space_groups (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- slug TEXT UNIQUE NOT NULL,-- name TEXT NOT NULL,-- description TEXT,-- position INTEGER NOT NULL DEFAULT 0--);----CREATE TABLE IF NOT EXISTS post_reactions (-- post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,-- subject_id TEXT NOT NULL,-- reaction TEXT NOT NULL,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (post_id, subject_id)--);--CREATE INDEX IF NOT EXISTS idx_post_reactions_post ON post_reactions(post_id, reaction);----CREATE TABLE IF NOT EXISTS post_pastes (-- post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,-- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,-- position INTEGER NOT NULL DEFAULT 0,-- PRIMARY KEY (post_id, paste_id)--);----CREATE TABLE IF NOT EXISTS categories (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,-- slug TEXT NOT NULL,-- name TEXT NOT NULL,-- description TEXT,-- position INTEGER NOT NULL DEFAULT 0,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- UNIQUE (space_id, slug)--);----CREATE TABLE IF NOT EXISTS space_chat_rooms (-- space_id INTEGER PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,-- room_id INTEGER,-- room_slug TEXT NOT NULL,-- room_name TEXT NOT NULL,-- room_kind TEXT NOT NULL DEFAULT 'community',-- room_visibility TEXT NOT NULL DEFAULT 'public',-- attached_by TEXT,-- attached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP--);----CREATE TABLE IF NOT EXISTS relay_mappings (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,-- direction TEXT NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),-- webhook_url_ref TEXT NOT NULL,-- enabled INTEGER NOT NULL DEFAULT 1,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- UNIQUE (space_id, direction, webhook_url_ref)--);----CREATE TABLE IF NOT EXISTS relay_deliveries (${RELAY_DELIVERIES_COLUMNS});--CREATE INDEX IF NOT EXISTS idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);----CREATE TABLE IF NOT EXISTS relay_message_map (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- platform TEXT NOT NULL DEFAULT 'discord' CHECK(platform IN ('discord')),-- mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,-- direction TEXT NOT NULL CHECK(direction IN ('out', 'in')),-- local_type TEXT NOT NULL CHECK(local_type IN ('thread', 'post')),-- local_id INTEGER NOT NULL,-- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,-- external_channel_id TEXT NOT NULL,-- external_thread_id TEXT,-- external_message_id TEXT NOT NULL,-- external_webhook_id TEXT,-- external_deleted_at DATETIME,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- UNIQUE (platform, external_message_id),-- UNIQUE (platform, mapping_id, local_type, local_id)--);--CREATE INDEX IF NOT EXISTS idx_relay_message_map_thread ON relay_message_map(thread_id);----CREATE TABLE IF NOT EXISTS relay_cursors (-- name TEXT PRIMARY KEY,-- cursor INTEGER NOT NULL,-- latest_seq INTEGER,-- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP--);----CREATE TABLE IF NOT EXISTS relay_inbound_failures (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- mapping_id INTEGER REFERENCES relay_mappings(id) ON DELETE SET NULL,-- event TEXT NOT NULL,-- external_channel_id TEXT,-- external_message_id TEXT,-- external_author_id TEXT,-- error TEXT NOT NULL,-- dismissed_at DATETIME,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP--);----CREATE TABLE IF NOT EXISTS pulse_items (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- source_service TEXT NOT NULL,-- source_type TEXT NOT NULL,-- source_id TEXT NOT NULL,-- title TEXT NOT NULL,-- url TEXT NOT NULL,-- actor_subject TEXT,-- origin TEXT NOT NULL CHECK(origin IN ('user', 'ai', 'system')),-- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public')),-- occurred_at DATETIME NOT NULL,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- UNIQUE (source_service, source_type, source_id)--);--CREATE INDEX IF NOT EXISTS idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);--CREATE TABLE IF NOT EXISTS game_progress (-- subject_id TEXT PRIMARY KEY,-- level INTEGER NOT NULL,-- updated_at TEXT NOT NULL--);--CREATE INDEX IF NOT EXISTS idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);----CREATE TABLE IF NOT EXISTS import_hold (-- source_type TEXT NOT NULL,-- source_id TEXT NOT NULL,-- reason TEXT NOT NULL,-- detail TEXT,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (source_type, source_id, reason)--);--`;----/**-- * SQL functions every connection needs (worker connections in tests too).-- *-- * ov_hot(score, age_hours) — the forum's "hot" rank. Deterministic: the same score and age-- * always give the same number, and callers pass one `now` for a whole page, so a page is-- * ranked against a single instant:-- *-- * hot = (score + 1) / (age_hours + 2) ^ 1.5-- *-- * +1 lets a thread nobody voted on yet still rank by age (instead of all tying at 0); +2 hours-- * keeps a brand-new thread from dividing by ~0; the 1.5 exponent is the Hacker News gravity.-- */--function registerFunctions(db) {-- db.function('ov_hot', { deterministic: true }, (score, ageHours) => (Number(score || 0) + 1) / Math.pow(Math.max(Number(ageHours) || 0, 0) + 2, 1.5));--}----/** Open (creating the directory and schema if needed). ':memory:' works for tests. */--function openDb(file) {-- if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });-- const db = new Database(file);-- db.pragma('journal_mode = WAL');-- db.pragma('foreign_keys = ON');-- db.pragma('busy_timeout = 5000');-- db.exec(SCHEMA);-- migrate(db);-- registerFunctions(db);-- return db;--}++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');/**-- * Additive changes to tables that already exist (CREATE TABLE IF NOT EXISTS never alters one).-- * Idempotent: every boot checks and fills in only what is missing.-- *-- * comment_threads.access_id — the unguessable handle browsers address a thread by (128 random-- * bits); the sequential id is for services only, so threads cannot be enumerated. Threads made-- * before the column existed get one here.-- * comments.edited_at — when the author last edited the text (null: never edited).-- * spaces.members_only_owner, threads.members_only_owner — members-only for a creator's OpenVibe.VIP-- * members (their Network subject); NULL: not gated. See forum/service.js.++ * 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.*/--function migrate(db) {-- const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));-- if (!cols.has('access_id')) db.exec('ALTER TABLE comment_threads ADD COLUMN access_id TEXT');-- const missing = db.prepare('SELECT id FROM comment_threads WHERE access_id IS NULL').all();-- if (missing.length) {-- const set = db.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?');-- db.transaction(() => { for (const r of missing) set.run(newThreadAccessId(), r.id); })();++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 community)');++ const dir = process.env.COMMUNITY_PGLITE_DIR || DEV_PGLITE; // tests that boot the real server give it a directory of its own++ log.warn(`[Community] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'community', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;}-- db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');-- const commentCols = new Set(db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));-- if (!commentCols.has('edited_at')) db.exec('ALTER TABLE comments ADD COLUMN edited_at DATETIME');-- // Members-only (OpenVibe.VIP): the creator (usr_…) whose members may read and post.-- for (const table of ['spaces', 'threads']) {-- const c = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((x) => x.name));-- if (!c.has('members_only_owner')) db.exec(`ALTER TABLE ${table} ADD COLUMN members_only_owner TEXT`);-- }-- // Categories, feature requests and the roadmap (WS-J tasks 1 and 8; forum/service.js):-- // spaces.thread_kind what a new thread in the space is: discussion, request (votes and a status set-- // by staff) or roadmap (staff and the roadmap sync only; forum/roadmap.js)-- // threads.kind/status/category_id/external_key the thread's kind, its status for requests and roadmap-- // items, its category, and the roadmap item key it was synced from-- const spaceCols = new Set(db.prepare('PRAGMA table_info(spaces)').all().map((x) => x.name));-- if (!spaceCols.has('thread_kind')) db.exec("ALTER TABLE spaces ADD COLUMN thread_kind TEXT NOT NULL DEFAULT 'discussion'");-- const threadCols = new Set(db.prepare('PRAGMA table_info(threads)').all().map((x) => x.name));-- if (!threadCols.has('kind')) db.exec("ALTER TABLE threads ADD COLUMN kind TEXT NOT NULL DEFAULT 'discussion'");-- if (!threadCols.has('status')) db.exec('ALTER TABLE threads ADD COLUMN status TEXT');-- if (!threadCols.has('category_id')) db.exec('ALTER TABLE threads ADD COLUMN category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL');-- if (!threadCols.has('external_key')) db.exec('ALTER TABLE threads ADD COLUMN external_key TEXT');-- db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL;-- CREATE INDEX IF NOT EXISTS idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL;-- INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, thread_kind) VALUES-- ('roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', 'roadmap');-- UPDATE spaces SET thread_kind = 'request', description = 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.'-- WHERE slug = 'feedback' AND thread_kind = 'discussion' AND created_by = 'system';`);-- // Space styles (forum/service.js): 'feed' (subreddit-like: votes, hot/new/top) or 'forum' (vBulletin/SMF-like:-- // board index, topics by last post, author panels, quotes). Votes and ratings are per-space switches;-- // group_id and position place a space on the board index, parent_id makes it a child board.-- for (const [col, ddl] of [['style', "TEXT NOT NULL DEFAULT 'feed'"], ['votes', 'INTEGER NOT NULL DEFAULT 1'], ['reactions', 'INTEGER NOT NULL DEFAULT 1'],-- ['group_id', 'INTEGER REFERENCES space_groups(id) ON DELETE SET NULL'], ['parent_id', 'INTEGER REFERENCES spaces(id) ON DELETE SET NULL'], ['position', 'INTEGER NOT NULL DEFAULT 0']]) {-- if (!spaceCols.has(col)) db.exec(`ALTER TABLE spaces ADD COLUMN ${col} ${ddl}`);-- }-- if (!threadCols.has('views')) db.exec('ALTER TABLE threads ADD COLUMN views INTEGER NOT NULL DEFAULT 0');-- if (!threadCols.has('crosspost_of')) db.exec('ALTER TABLE threads ADD COLUMN crosspost_of INTEGER REFERENCES threads(id) ON DELETE SET NULL');-- // The first board index: two groups, the four original spaces placed once (a space staff moved is left alone),-- // and two new forum boards.-- db.exec(`INSERT OR IGNORE INTO space_groups (slug, name, description, position) VALUES-- ('openvibe', 'OpenVibe', 'The network itself: talk, help, requests and what is coming next.', 1),-- ('community', 'Community', 'What the people of OpenVibe make, and everything else.', 2);-- INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, style, votes) VALUES-- ('help', 'Help', 'Stuck on something? Ask here: streaming, tools, your account, anything on OpenVibe.', 'public', 'system', 'forum', 0),-- ('off-topic', 'Off-topic', 'Anything that is not about OpenVibe.', 'public', 'system', 'forum', 0);`);-- const place = db.prepare(`UPDATE spaces SET group_id = (SELECT id FROM space_groups WHERE slug = ?), position = ?, style = ?, votes = ?-- WHERE slug = ? AND created_by = 'system' AND group_id IS NULL`);-- for (const [group, position, style, votes, slug] of [-- ['openvibe', 1, 'forum', 0, 'general'], ['openvibe', 2, 'forum', 0, 'help'], ['openvibe', 3, 'feed', 1, 'feedback'], ['openvibe', 4, 'feed', 1, 'roadmap'],-- ['community', 1, 'feed', 1, 'showcase'], ['community', 2, 'forum', 0, 'off-topic'],-- ]) place.run(group, position, style, votes, slug);-- const seedCategory = db.prepare(`INSERT OR IGNORE INTO categories (space_id, slug, name, description, position)-- SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?`);-- for (const [space, slug, name, description, position] of [-- ['feedback', 'ideas', 'Ideas', 'Something new, or something better.', 1],-- ['feedback', 'bugs', 'Bugs', 'Something is broken or wrong.', 2],-- ['feedback', 'questions', 'Questions', 'How do I…? Why does…?', 3],-- ['roadmap', 'launches', 'New sites', 'Sites that open next.', 1],-- ['roadmap', 'features', 'Features', 'New things on sites that are already open.', 2],-- ['roadmap', 'platform', 'Under the hood', 'Accounts, safety, reliability and the shared systems every site uses.', 3],-- ]) seedCategory.run(slug, name, description, position, space);-- // The Discord relay both ways (WS-J tasks 5 and 6; server/relay): where a mapping's webhook posts and-- // whether replies come back, the Discord name on posts from Discord, and relay_deliveries rebuilt once-- // for replies, edits and deletes (the older table allowed one row per (thread, mapping) and no actions).-- const mappingCols = new Set(db.prepare('PRAGMA table_info(relay_mappings)').all().map((x) => x.name));-- if (!mappingCols.has('discord_channel_id')) db.exec('ALTER TABLE relay_mappings ADD COLUMN discord_channel_id TEXT');-- if (!mappingCols.has('discord_thread_id')) db.exec('ALTER TABLE relay_mappings ADD COLUMN discord_thread_id TEXT');-- if (!mappingCols.has('inbound')) db.exec('ALTER TABLE relay_mappings ADD COLUMN inbound INTEGER NOT NULL DEFAULT 0');-- const postCols = new Set(db.prepare('PRAGMA table_info(posts)').all().map((x) => x.name));-- if (!postCols.has('relay_author')) db.exec('ALTER TABLE posts ADD COLUMN relay_author TEXT');-- const deliveryCols = new Set(db.prepare('PRAGMA table_info(relay_deliveries)').all().map((x) => x.name));-- if (!deliveryCols.has('action')) {-- db.transaction(() => {-- db.exec(`CREATE TABLE relay_deliveries_v2 (${RELAY_DELIVERIES_COLUMNS});-- INSERT INTO relay_deliveries_v2 (id, thread_id, mapping_id, dedupe_key, status, attempts, next_attempt_at, last_status, last_error, delivered_at, created_at, updated_at)-- SELECT id, thread_id, mapping_id, dedupe_key, status, attempts, next_attempt_at, last_status, last_error, delivered_at, created_at, updated_at FROM relay_deliveries;-- DROP TABLE relay_deliveries;-- ALTER TABLE relay_deliveries_v2 RENAME TO relay_deliveries;-- CREATE INDEX IF NOT EXISTS idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);`);-- })();-- }-- db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_deliveries_thread ON relay_deliveries(thread_id, post_id);-- CREATE INDEX IF NOT EXISTS idx_relay_mappings_channel ON relay_mappings(discord_channel_id) WHERE discord_channel_id IS NOT NULL;`);++ 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: 'community-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'community', registry, log });}/** cth_ + 22 base64url characters (16 random bytes). */function newThreadAccessId() { return `cth_${crypto.randomBytes(16).toString('base64url')}`; }let _shared = null;--/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */++/** Open the process-wide database once, at boot (server/index.js). */++async function initDb(config, opts) {++ if (!_shared) _shared = await openDb(config, opts);++ return _shared;++}++/** The process-wide database initDb() opened. */function getDb() {-- if (!_shared) _shared = openDb(require('./config').dbPath);++ if (!_shared) throw new Error('the database is not open: await initDb(config) at boot');return _shared;}++/** Tests: use this handle as the process-wide database. */++function setDb(db) { _shared = db; }--/** Graceful stop: close the process-wide database (a later getDb() opens it again). */--function closeDb() {++/** Graceful stop: close the process-wide database. */++async function closeDb() {const db = _shared;_shared = null;-- if (db) try { db.close(); } catch { /* already closed */ }++ if (db) await db.close().catch(() => {});}--module.exports = { openDb, getDb, closeDb, registerFunctions, SCHEMA, newThreadAccessId };++module.exports = { openDb, initDb, getDb, setDb, closeDb, MIGRATIONS, newThreadAccessId };@@@ -16,10 +16,11 @@*/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 config = require('./config');let outbox = null;++let outboxDb = null; // the handle enqueue() writes through (it joins the caller's ambient transaction)let pruneTimer = null;const stats = { queued: 0, lastError: null };const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;@@@ -29,16 +30,17 @@ function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oaif (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'community', clientSecret, fetch: fetchImpl });const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });-- outbox = createOutbox(db, {++ outboxDb = db;++ // The PostgreSQL outbox (its table is in migrations/0001_initial.sql); several processes relay it safely (leases).++ outbox = createPgOutbox(db, {events: createEventsClient(client, { source: 'community' }),intervalMs: intervalMs || 2000,onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },});-- outbox.ensureSchema();outbox.start();-- pruneTimer = setInterval(() => { try { outbox.prune(); } catch { /* next time */ } }, PRUNE_EVERY_MS);++ pruneTimer = setInterval(() => { outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);if (pruneTimer.unref) pruneTimer.unref();-- console.log(`[Events] community → ${eventsUrl} (${outbox.pending()} pending)`);++ outbox.pending().then((n) => console.log(`[Events] community → ${eventsUrl} (${n} pending)`), () => {});return outbox;}@@@ -48,9 +50,9 @@ const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? {* Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,* so the change rolls back with it. A no-op (null) while publishing is off.*/--function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {++async function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {if (!outbox) return null;-- const env = outbox.enqueue({++ const env = await outbox.enqueue(outboxDb, {event_type: eventType,actor: actor || { type: 'service', id: 'community' },subject,@@@ -59,7 +61,7 @@ function record(eventType, subject, payload, { isPublic = false, actor = null }payload,});stats.queued++;-- setImmediate(() => outbox && outbox.kick());++ outboxDb.afterCommit(() => outbox && outbox.kick());return env;}@@@ -67,34 +69,34 @@ function record(eventType, subject, payload, { isPublic = false, actor = null }const pasteUrl = (p) => (p.visibility === 'public' && !p.deleted_at ? `${config.baseUrl}/p/${encodeURIComponent(p.slug)}` : null);const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };--function pasteCreated(p) {-- return record('community.paste.created', { type: 'paste', id: p.slug }, {++async function pasteCreated(p) {++ return await record('community.paste.created', { type: 'paste', id: p.slug }, {paste_id: p.slug, type: p.type === 'screenshot' ? 'screenshot' : 'paste', origin: ['user', 'ai', 'import'].includes(p.origin) ? p.origin : 'user',visibility: p.visibility || 'public', owner: subjectRef(p.owner_subject), url: pasteUrl(p),}, { isPublic: p.visibility === 'public', actor: actorOf(p.owner_subject) });}--function pasteUpdated(p, changed) {++async function pasteUpdated(p, changed) {const list = [...new Set(changed)].filter((c) => ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].includes(c));if (!list.length) return null;-- return record('community.paste.updated', { type: 'paste', id: p.slug, revision: Number(p.revision) || 1 }, {++ return await record('community.paste.updated', { type: 'paste', id: p.slug, revision: Number(p.revision) || 1 }, {paste_id: p.slug, changed: list, visibility: p.deleted_at ? 'deleted' : (p.visibility || 'public'), revision: Number(p.revision) || 1, url: pasteUrl(p),}, { isPublic: p.visibility === 'public' });}--function pasteDeleted(slug) {-- return record('community.paste.deleted', { type: 'paste', id: slug }, { paste_id: slug }, { isPublic: true });++async function pasteDeleted(slug) {++ return await record('community.paste.deleted', { type: 'paste', id: slug }, { paste_id: slug }, { isPublic: true });}// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');--function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {++async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {const visibility = forumVisibility(thread, spaceVisibility);-- return record('community.thread.created', { type: 'thread', id: String(thread.id) }, {++ return await record('community.thread.created', { type: 'thread', id: String(thread.id) }, {thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,}, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });}--function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {++async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {const visibility = forumVisibility(thread, spaceVisibility);-- return record('community.post.created', { type: 'post', id: String(post.id) }, {++ return await record('community.post.created', { type: 'post', id: String(post.id) }, {post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });@@@ -104,10 +106,10 @@ function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {* refIsPublic): the thread's access id and its ref (an unlisted paste's slug, a private VOD's id) are* what keep a private item's comments private, and public events reach every realtime listener and app.*/--function commentCreated(comment, cthread, { itemPublic = false } = {}) {++async function commentCreated(comment, cthread, { itemPublic = false } = {}) {const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';const isPublic = visibility !== 'hidden' && !!itemPublic;-- return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {++ return await record('community.comment.created', { type: 'comment', id: String(comment.id) }, {comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },author: subjectRef(comment.author_subject), visibility, url: isPublic ? `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}` : null,}, { isPublic, actor: actorOf(comment.author_subject) });@@@ -117,17 +119,23 @@ function commentCreated(comment, cthread, { itemPublic = false } = {}) {* A staff action on someone else's content: community.moderation.action, for the network's* moderation audit log (ADR-022). Call inside the same transaction as the change. Never the content.*/--function moderationAction(v, action, target, { reason = null, details = {} } = {}) {++async function moderationAction(v, action, target, { reason = null, details = {} } = {}) {const actorSubject = v && v.subject ? v.subject : null;const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };-- return record('community.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },++ return await record('community.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },{ action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },{ actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });}++/** Synchronous: whether the relay is on, and what this process queued. */function status() {if (!outbox) return { enabled: false };-- return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };++ return { enabled: true, queued_since_boot: stats.queued, last_error: stats.lastError };++}++/** status() with the outbox's backlog (rows not sent yet, rows Events refused). */++async function backlog() {++ if (!outbox) return { enabled: false };++ return { ...status(), pending: await outbox.pending(), rejected: await outbox.rejected() };}/*** Graceful stop (server/index.js): no further sends; resolves when the send in progress has finished.@@@ -138,6 +146,6 @@ function stop() {pruneTimer = null;return outbox ? outbox.stop() : Promise.resolve();}--function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }++function _reset() { if (outbox) outbox.stop(); outbox = null; outboxDb = null; stats.queued = 0; stats.lastError = null; }--module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, stop, _reset };++module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, backlog, stop, _reset };@@@ -60,35 +60,35 @@ function createSpacesApi({ forum, viewers, limits }) {const configure = limits('community.space.configure', { minute: 30, hour: 300 }); // moderators' settingsconst editThread = limits('community.thread.edit', { minute: 30, hour: 300 });-- router.get('/', run((req) => forum.listSpaces(req.viewer)));-- router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.createSpace(req.viewer, req.body || {}), 201));-- router.put('/:space/settings', serviceCap(MOD), configure, jsonBody, run((req) => forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));-- router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));-- router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));-- router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));++ router.get('/', run(async (req) => await forum.listSpaces(req.viewer)));++ router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));++ router.put('/:space/settings', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));++ router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));++ router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));++ router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));// An image to attach: multipart `file`; then name its media_id in `attachments` when posting.const one = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 5 } }).single('file');const withFile = (req, res, next) => one(req, res, (err) => (err? contracts.http.sendProblem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.code === 'LIMIT_FILE_SIZE' ? 'attachments.too_large' : 'attachments.invalid', { detail: err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : 'Send one image as multipart field `file`', ctx: req.ov }): next()));// An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.-- router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run((req) => forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));-- router.put('/:space/categories/:category', serviceCap(MOD), configure, jsonBody, run((req) => forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));-- router.delete('/:space/categories/:category', serviceCap(MOD), configure, run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));-- router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/status', serviceCap(MOD), configure, jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));-- router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));-- router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));-- router.delete('/:space/threads/:slug', writeOrMod, editThread, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));-- router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));-- router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/state', serviceCap(MOD), configure, jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));-- router.put('/:space/members-only', serviceCap(MOD), configure, jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));++ router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run(async (req) => await forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));++ router.put('/:space/categories/:category', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));++ router.delete('/:space/categories/:category', serviceCap(MOD), configure, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));++ router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/threads/:slug/status', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));++ router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));++ router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));++ router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));++ router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));++ router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/threads/:slug/state', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/members-only', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));// A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.-- router.put('/:space/chat-room', configure, jsonBody, run((req) => forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));-- router.delete('/:space/chat-room', serviceCap(MOD), configure, run((req) => forum.detachChatRoom(req.viewer, p(req).space)));++ router.put('/:space/chat-room', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));++ router.delete('/:space/chat-room', serviceCap(MOD), configure, run(async (req) => await forum.detachChatRoom(req.viewer, p(req).space)));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;@@@ -102,10 +102,10 @@ function createPostsApi({ forum, viewers, limits }) {// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.router.use(limits.reads('community.forum.read'));-- router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));-- router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run((req) => forum.react(req.viewer, req.params.id, req.body || {})));-- router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), run((req) => forum.deletePost(req.viewer, req.params.id)));-- router.get('/:id/versions', writeOrMod, run((req) => forum.postVersions(req.viewer, req.params.id)));++ router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));++ router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));++ router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), run(async (req) => await forum.deletePost(req.viewer, req.params.id)));++ router.get('/:id/versions', writeOrMod, run(async (req) => await forum.postVersions(req.viewer, req.params.id)));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;@@@ -119,7 +119,7 @@ function createGroupsApi({ forum, viewers, limits }) {// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.router.use(limits.reads('community.forum.read'));router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));-- router.put('/:group', serviceCap(MOD), limits('community.space.configure', { minute: 30, hour: 300 }), jsonBody, run((req) => forum.putGroup(req.viewer, req.params.group, req.body || {})));++ router.put('/:group', serviceCap(MOD), limits('community.space.configure', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.putGroup(req.viewer, req.params.group, req.body || {})));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;}@@@ -21,14 +21,14 @@ const BY_KEY = new Map(REACTIONS.map((r) => [r.key, r]));const ORDER = new Map(REACTIONS.map((r, i) => [r.key, i]));/** Set (or with null, remove) a person's rating of a post. → the rating now held, or null */--function setReaction(db, postId, subject, reaction) {-- const cur = db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);++async function setReaction(db, postId, subject, reaction) {++ const cur = await db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);if (!reaction || (cur && cur.reaction === reaction)) {-- db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);++ await db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);return null;}-- db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)-- ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = CURRENT_TIMESTAMP`).run(postId, subject, reaction);++ await db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)++ ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = ov_now()`).run(postId, subject, reaction);return reaction;}@@@ -38,10 +38,10 @@ const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');* Ratings of these posts. → Map postId → [{ key, emoji, label, group, count, raters: [subject…] (first 8), mine }]* in the fixed order, only ratings someone gave.*/--function reactionsFor(db, postIds, viewerSubject = null) {++async function reactionsFor(db, postIds, viewerSubject = null) {const out = new Map();if (!postIds.length) return out;-- const rows = db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);++ const rows = await db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);for (const r of rows) {const def = BY_KEY.get(r.reaction);if (!def) continue;@@@ -57,11 +57,11 @@ function reactionsFor(db, postIds, viewerSubject = null) {}/** The ratings each author has received, most given first (top 3). → Map subject → [{ key, emoji, label, count }] */--function receivedBy(db, subjects) {++async function receivedBy(db, subjects) {const out = new Map();const list = [...new Set(subjects.filter(Boolean))];if (!list.length) return out;-- const rows = db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id++ const rows = await db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_idWHERE p.author_subject IN (${placeholders(list.length)}) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction`).all(...list);for (const r of rows) {const def = BY_KEY.get(r.reaction);@@@ -16,9 +16,9 @@ const FILE = path.join(__dirname, '..', '..', 'docs', 'roadmap', 'public.json');const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;/** items: [{ key, title, status, category?, summary }] → { created, updated, unchanged, skipped } */--function syncRoadmap(db, items, { log = console } = {}) {++async function syncRoadmap(db, items, { log = console } = {}) {const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };-- const space = store.getSpace(db, 'roadmap');++ const space = await store.getSpace(db, 'roadmap');if (!space) return out;for (const item of Array.isArray(items) ? items : []) {const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();@@@ -28,31 +28,31 @@ function syncRoadmap(db, items, { log = console } = {}) {log.warn && log.warn(`[Roadmap] skipped an item that is not { key, title, status, summary }: ${JSON.stringify(item && item.key)}`);continue;}-- const category = item.category ? store.getCategory(db, space.id, item.category) : null;++ const category = item.category ? await store.getCategory(db, space.id, item.category) : null;const categoryId = category ? category.id : null;-- const existing = store.getThreadByKey(db, space.id, item.key);++ const existing = await store.getThreadByKey(db, space.id, item.key);if (!existing) {-- store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });++ await store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });out.created++;continue;}let changed = false;if (existing.title !== title || existing.status !== item.status || (existing.category_id || null) !== categoryId) {-- db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);++ await db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);changed = true;}-- const opening = db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);-- if (opening && opening.body_markdown !== summary) { store.editPost(db, opening.id, summary, 'system'); changed = true; }++ const opening = await db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);++ if (opening && opening.body_markdown !== summary) { await store.editPost(db, opening.id, summary, 'system'); changed = true; }if (changed) out.updated++; else out.unchanged++;}return out;}/** Sync from docs/roadmap/public.json (or `file`). A missing or broken file changes nothing. */--function syncFromFile(db, { file = FILE, log = console } = {}) {++async function syncFromFile(db, { file = FILE, log = console } = {}) {let doc;try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Roadmap] ${file}: ${err.message}`); return null; }-- return syncRoadmap(db, doc.items, { log });++ return await syncRoadmap(db, doc.items, { log });}module.exports = { syncRoadmap, syncFromFile, FILE };@@@ -78,7 +78,7 @@ function createForumRoutes({ forum, viewers, config }) {try {if (!req.user) return login(res, '/s/new-space');if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create spaces');-- html(res, boardPages.newSpacePage({ groups: forum.groups() }));++ html(res, boardPages.newSpacePage({ groups: await forum.groups() }));} catch (err) { failPage(req, res, err, next); }}));router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {@@@ -90,7 +90,7 @@ function createForumRoutes({ forum, viewers, config }) {res.redirect(303, `/s/${out.space.slug}`);} catch (err) {if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);-- html(res, boardPages.newSpacePage({ groups: forum.groups(), error: err.message, values }), err.status);++ html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);}}));@@@ -103,14 +103,14 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.get('/s/feed.xml', (_req, res) => {-- res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ threads: forum.recentPublic({ limit: 30 }) }));++ router.get('/s/feed.xml', async (_req, res) => {++ res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ threads: await forum.recentPublic({ limit: 30 }) }));});-- router.get('/s/:space/feed.xml', (req, res) => {-- const space = forum.publicSpaces().find((s) => s.slug === req.params.space);++ router.get('/s/:space/feed.xml', async (req, res) => {++ const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);if (!space) return res.status(404).type('text/plain').send('Not found');-- res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ space, threads: forum.recentPublic({ limit: 30, space: space.slug }) }));++ res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ space, threads: await forum.recentPublic({ limit: 30, space: space.slug }) }));});router.get('/s/:space', withViewer, wrap(async (req, res, next) => {@@@ -130,7 +130,7 @@ function createForumRoutes({ forum, viewers, config }) {if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');const { space } = await forum.space(req.viewer, req.params.space);const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';-- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: forum.pasteTitle(paste) || '' } : {} }));++ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: await forum.pasteTitle(paste) || '' } : {} }));} catch (err) { failPage(req, res, err, next); }}));@@@ -149,7 +149,7 @@ function createForumRoutes({ forum, viewers, config }) {if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);try {const { space } = await forum.space(req.viewer, req.params.space);-- const { categories } = forum.categories(req.viewer, req.params.space);++ const { categories } = await forum.categories(req.viewer, req.params.space);html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories, attachments: forum.attachmentsEnabled() }), err.status);} catch (e) { failPage(req, res, e, next); }}@@@ -158,7 +158,7 @@ function createForumRoutes({ forum, viewers, config }) {async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');-- if (status === 200 && forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;++ if (status === 200 && await forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;// Quote (no JS): ?quote=<post id> fills the reply box with the quoted post.if (!draft && /^\d{1,15}$/.test(String(req.query.quote || '')) && out.viewer.can_reply) {try { draft = (await forum.quote(req.viewer, req.query.quote)).markdown; } catch { /* a post that is gone: an empty box */ }@@@ -280,9 +280,9 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, (req, res, next) => {++ router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {try {-- forum.deleteThread(req.viewer, req.params.space, req.params.slug);++ await forum.deleteThread(req.viewer, req.params.space, req.params.slug);seo.resetCaches();res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);} catch (err) { failPage(req, res, err, next); }@@@ -107,13 +107,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi// Views: one per viewer (person, or address) per thread per 30 minutes; memory only.const viewSeen = new Map();const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;-- const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };++ const hook = async (fn) => { try { await fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };const moderator = (v) => discussionModerator(v);const person = (v) => !!(v && v.subject);// ── a space's chat room (OpenVibe.Chat) ──────────────────-- const chatRoomRow = (spaceId) => (spaceId == null ? null : db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);++ const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);const shapeChatRoom = (r) => (r ? {slug: r.room_slug, name: r.room_name, kind: r.room_kind, visibility: r.room_visibility,url: chatRooms ? chatRooms.roomUrl(r.room_slug) : `https://openvibe.chat/r/${encodeURIComponent(r.room_slug)}`,@@@ -173,27 +173,27 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi}/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */-- function spaceFor(v, slug) {-- const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? store.getSpace(db, slug) : null;++ async function spaceFor(v, slug) {++ const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');return space;}-- function threadFor(v, spaceSlug, threadSlug) {-- const space = spaceFor(v, spaceSlug);-- const thread = store.getThreadBySlug(db, space.id, threadSlug);++ async function threadFor(v, spaceSlug, threadSlug) {++ const space = await spaceFor(v, spaceSlug);++ const thread = await store.getThreadBySlug(db, space.id, threadSlug);if (!thread) fail(404, 'thread.not_found', 'No such thread');return { space, thread };}-- function postFor(v, postId) {-- const post = /^\d{1,15}$/.test(String(postId)) ? store.getPost(db, Number(postId)) : null;-- const thread = post ? store.getThread(db, post.thread_id) : null;-- const space = thread ? store.getSpaceById(db, thread.space_id) : null;++ async function postFor(v, postId) {++ const post = /^\d{1,15}$/.test(String(postId)) ? await store.getPost(db, Number(postId)) : null;++ const thread = post ? await store.getThread(db, post.thread_id) : null;++ const space = thread ? await store.getSpaceById(db, thread.space_id) : null;if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');// A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).-- try { spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }++ try { await spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }return { post, thread, space };}@@@ -221,13 +221,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);-- function shapeThread(t, space, v, projections, votes) {++ async function shapeThread(t, space, v, projections, votes) {const mo = t.members_only_owner || null;const p = mo && projections ? projections.get(mo) : null;return {id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),kind: t.kind || 'discussion', status: t.status || null,-- category: t.category_id ? shapeCategory(store.getCategoryById(db, t.category_id)) : null,++ category: t.category_id ? shapeCategory(await store.getCategoryById(db, t.category_id)) : null,members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count, views: t.views || 0,@@@ -241,43 +241,43 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, mime: a.mime, size_bytes: a.size_bytes });/** post id → [attachment], in order. */-- function attachmentsOf(postIds) {++ async function attachmentsOf(postIds) {const out = new Map();if (!postIds.length) return out;-- const rows = db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);++ const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }return out;}/** The attachments a write names: the writer's own uploads, not yet on a post, at most ATTACH_MAX. → rows */-- function claimable(w, ids) {++ async function claimable(w, ids) {if (ids === undefined || ids === null) return [];const list = Array.isArray(ids) ? ids.map(String) : [String(ids)];if (list.length > ATTACH_MAX) fail(400, 'attachments.too_many', `At most ${ATTACH_MAX} images per post`);if (!w.author && list.length) fail(403, 'attachments.person_only', 'Only people attach images');-- const rows = list.map((id) => (MED_ID.test(id) ? db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(id) : null));++ const rows = (await Promise.all(list.map(async (id) => (MED_ID.test(id) ? await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(id) : null))));if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');return rows;}-- function claimPastes(refs) {++ async function claimPastes(refs) {const slugs = parsePasteRefs(refs);if (slugs.length > PASTE_MAX) fail(400, 'pastes.too_many', `At most ${PASTE_MAX} pastes per post`);-- return slugs.map((slug) => {-- const r = db.prepare('SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?').get(slug);++ return (await Promise.all(slugs.map(async (slug) => {++ const r = await db.prepare('SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?').get(slug);if (!r || r.deleted_at) fail(404, 'pastes.not_found', `No paste ${slug}`);if (r.visibility === 'private' || r.burn_after_read) fail(400, 'pastes.not_shareable', `Paste ${slug} is private or burns after reading: it cannot be attached`);return r;-- });++ })));}-- function attachPastes(rows, postId) {-- const ins = db.prepare('INSERT OR IGNORE INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?)');-- rows.forEach((r, i) => ins.run(postId, r.id, i));++ async function attachPastes(rows, postId) {++ const ins = db.prepare('INSERT INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?) ON CONFLICT DO NOTHING');++ for (const [i, r] of rows.entries()) await ins.run(postId, r.id, i);}/** post id → [paste card], live shareable pastes only (a paste made private or deleted drops out). */-- function pastesOf(postIds) {++ async function pastesOf(postIds) {const out = new Map();if (!postIds.length) return out;-- const rows = db.prepare(`SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.content++ const rows = await db.prepare(`SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.contentFROM post_pastes pp JOIN pastes p ON p.id = pp.paste_idWHERE pp.post_id IN (${postIds.map(() => '?').join(', ')}) AND p.deleted_at IS NULL AND p.visibility != 'private' AND p.burn_after_read = 0ORDER BY pp.post_id, pp.position`).all(...postIds);@@@ -290,9 +290,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn out;}-- function attach(rows, postId) {++ async function attach(rows, postId) {const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');-- rows.forEach((r, i) => set.run(postId, i, r.media_id));++ for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);}function shapePost(p, v, projections, attachments = null, pastes = null) {@@@ -313,11 +313,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync function shapeThreads(rows, spaceOf, v) {const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner), ...rows.map((r) => r.last_author_subject)]);-- const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);-- return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));++ const votes = await myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);++ return (await Promise.all(rows.map(async (r) => await shapeThread(r, spaceOf(r), v, projections, votes))));}-- function shapeSpace(s, mo = null) {++ async function shapeSpace(s, mo = null) {return {slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],@@@ -328,7 +328,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vimembers_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,thread_count: s.thread_count != null ? s.thread_count : undefined,last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,-- chat_room: shapeChatRoom(chatRoomRow(s.id)),++ chat_room: shapeChatRoom(await chatRoomRow(s.id)),};}@@@ -336,21 +336,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* Newly gated threads leave Pulse at once, and Discord: what the relay had not sent yet is skipped and* what it had sent is deleted there (reads and sends re-check as well).*/-- function hideGated(threadIds) {++ async function hideGated(threadIds) {if (!threadIds.length) return;-- if (pulse) hook(() => { for (const id of threadIds) { pulse.threadGone(id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(id)) pulse.postGone(p.id); } });-- if (relay) hook(() => relay.hideThreads(threadIds, 'made members-only'));++ if (pulse) await hook(async () => { for (const id of threadIds) { await pulse.threadGone(id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(id)) await pulse.postGone(p.id); } });++ if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));}-- function removeThread(v, thread) {++ async function removeThread(v, thread) {const mine = person(v) && thread.author_subject === v.subject;if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');-- db.transaction(() => {-- store.softDeleteThread(db, thread.id);-- if (!mine) events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });-- })();-- if (pulse) hook(() => { pulse.threadGone(thread.id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) pulse.postGone(p.id); });-- if (relay) hook(() => relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));++ await db.tx(async () => {++ await store.softDeleteThread(db, thread.id);++ if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ });++ if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });++ if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));return { ok: true, id: thread.id, deleted: 'thread' };}@@@ -358,33 +358,33 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* Where a thread came from and where else it was crossposted, as far as the viewer may see.* → { from: { space, title, url } | null, to: [{ space, title, url }], targets: [{ slug, name }] (spaces the viewer may crosspost to) }*/-- function crosspostInfo(v, thread) {++ async function crosspostInfo(v, thread) {const visible = (sp) => sp && !sp.members_only_owner && canRead(v, sp) && (sp.visibility !== 'staff' || moderator(v));let from = null;if (thread.crosspost_of) {-- const t = store.getThread(db, thread.crosspost_of);-- const sp = t ? store.getSpaceById(db, t.space_id) : null;++ const t = await store.getThread(db, thread.crosspost_of);++ const sp = t ? await store.getSpaceById(db, t.space_id) : null;if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };}-- const to = db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id)-- .map((r) => ({ r, sp: store.getSpace(db, r.space_slug) })).filter(({ sp }) => visible(sp))++ const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id))++ .map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp)).map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));const targets = person(v) && !thread.members_only_owner-- ? store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members']).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))++ ? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v))).map((sp) => ({ slug: sp.slug, name: sp.name })): [];return { from, to, targets };}/** Where a space sits on the board index (breadcrumbs). → { group, parent } */-- function placeOf(space) {-- const g = space.group_id ? db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;-- const p = space.parent_id ? db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;++ async function placeOf(space) {++ const g = space.group_id ? await db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;++ const p = space.parent_id ? await db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;return { group: g || null, parent: p || null };}/** Validated space fields from a settings body (only what was sent). */-- function spaceFields(body, space) {++ async function spaceFields(body, space) {const f = {};if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Space names are 2 to 60 characters'); f.name = n; }if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;@@@ -393,12 +393,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }if (body.group !== undefined) {-- const g = body.group ? db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;++ const g = body.group ? await db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;if (body.group && !g) fail(404, 'group.not_found', 'No such group on the board index');f.group_id = g ? g.id : null;}if (body.parent !== undefined) {-- const p = body.parent ? store.getSpace(db, String(body.parent)) : null;++ const p = body.parent ? await store.getSpace(db, String(body.parent)) : null;if (body.parent && !p) fail(404, 'space.not_found', 'No such parent space');if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A space cannot sit under itself or its own child');if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');@@@ -415,11 +415,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, viisModerator: moderator,/** The board index's groups (for the settings and new-space forms). */-- groups: () => store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })),++ groups: async () => (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })),/** A shareable paste's title (the new-topic form's "Discuss in a space"), or null. */-- pasteTitle(slug) {-- const r = db.prepare("SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0").get(String(slug));++ async pasteTitle(slug) {++ const r = await db.prepare("SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0").get(String(slug));return r ? r.title : null;},@@@ -431,11 +431,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst vis = ['public'];if (person(v) || moderator(v)) vis.push('members');if (moderator(v)) vis.push('staff');-- const rows = store.listSpaces(db, vis);-- const last = store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));++ const rows = await store.listSpaces(db, vis);++ const last = await store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);const shaped = await Promise.all(rows.map(async (r) => {-- const sp = shapeSpace(r, await membersOnly(r.members_only_owner, projections));++ const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));const l = last.get(r.id);sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;return sp;@@@ -444,7 +444,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst bySlug = new Map(shaped.map((sp) => [sp.slug, { ...sp, children: [] }]));for (const sp of bySlug.values()) if (sp.parent && bySlug.has(sp.parent.slug)) bySlug.get(sp.parent.slug).children.push(sp);const top = [...bySlug.values()].filter((sp) => !(sp.parent && bySlug.has(sp.parent.slug)));-- const groups = store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) }))++ const groups = (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) })).filter((g) => g.spaces.length);const other = top.filter((sp) => !sp.group);if (other.length) groups.push({ slug: null, name: 'More spaces', description: null, spaces: other });@@@ -452,15 +452,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi},/** The board index's groups — moderators create or change one { name, description?, position? }. */-- putGroup(v, slug, body = {}) {++ async putGroup(v, slug, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage the board index');if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'group.invalid_slug', 'A group slug is 1 to 40 lowercase letters, digits and dashes');const name = cleanTitle(body.name);if (name.length < 2 || name.length > 60) fail(400, 'group.invalid_name', 'Group names are 2 to 60 characters');-- db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)++ await db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`).run(slug, name, body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null, Number.isInteger(Number(body.position)) ? Number(body.position) : 0);-- return { group: db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };++ return { group: await db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };},/**@@@ -469,11 +469,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi*/async updateSpaceSettings(v, spaceSlug, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a space');-- const space = spaceFor(v, spaceSlug);-- const fields = spaceFields(body, space);-- const next = store.updateSpace(db, space.id, fields);-- const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.id === next.id) || next;-- return { space: shapeSpace(row, await membersOnly(row.members_only_owner)) };++ const space = await spaceFor(v, spaceSlug);++ const fields = await spaceFields(body, space);++ const next = await store.updateSpace(db, space.id, fields);++ const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;++ return { space: await shapeSpace(row, await membersOnly(row.members_only_owner)) };},/** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */@@@ -481,21 +481,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create spaces');const slug = String(body.slug || '').trim().toLowerCase();if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A space slug is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');-- if (store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');++ if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';-- const fields = spaceFields({ ...body, name: body.name }, null);++ const fields = await spaceFields({ ...body, name: body.name }, null);if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');const style = fields.style || 'feed';-- store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),++ await store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });-- const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.slug === slug);-- return { space: shapeSpace(row) };++ const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.slug === slug);++ return { space: await shapeSpace(row) };},async space(v, slug) {-- const s = spaceFor(v, slug);-- return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };++ const s = await spaceFor(v, slug);++ return { space: await shapeSpace(s, await membersOnly(s.members_only_owner)) };},/**@@@ -504,34 +504,34 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* another room replaces it (Chat is told the space let go of the old one). → { chat_room, created }*/async attachChatRoom(v, spaceSlug, body = {}) {-- const space = spaceFor(v, spaceSlug);++ const space = await spaceFor(v, spaceSlug);if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');const slug = chatRooms.parseRoomRef(body.room);if (!slug) fail(400, 'chat_room.invalid', 'Name the room by its address (night-owls) or its link (https://openvibe.chat/r/night-owls)');-- const before = chatRoomRow(space.id);++ const before = await chatRoomRow(space.id);const out = await chatRooms.attach({ token: v.token, room: slug, space: space.slug, title: space.name });const same = !!before && before.room_slug === out.room.slug;-- db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)++ await db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name,room_kind = excluded.room_kind, room_visibility = excluded.room_visibility,attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END,-- attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE CURRENT_TIMESTAMP END`)++ attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE ov_now() END`).run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });-- return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(chatRoomRow(space.id)), created: !same };++ return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };},/** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */async detachChatRoom(v, spaceSlug) {-- const space = spaceFor(v, spaceSlug);++ const space = await spaceFor(v, spaceSlug);if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');-- const before = chatRoomRow(space.id);++ const before = await chatRoomRow(space.id);if (!before) return { detached: false, chat: null };-- db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);++ await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);// Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';return { detached: true, chat };@@@ -539,27 +539,27 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */async listThreads(v, spaceSlug, q = {}) {-- const space = spaceFor(v, spaceSlug);++ const space = await spaceFor(v, spaceSlug);await requireMembership(v, space);const forumStyle = space.style === 'forum';let sort = store.SORTS.includes(q.sort) ? q.sort : (forumStyle ? 'active' : 'hot');if (sort === 'top' && space.votes === 0) sort = forumStyle ? 'active' : 'hot';const page = Math.max(parseInt(q.page, 10) || 1, 1);const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);-- const category = q.category ? store.getCategory(db, space.id, q.category) : null;++ const category = q.category ? await store.getCategory(db, space.id, q.category) : null;if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');const statuses = STATUSES[space.thread_kind] || [];if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');-- const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });++ const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });const index = await this.listSpaces(v);const self = index.spaces.find((x) => x.slug === space.slug) || {};return {-- space: { ...shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },++ space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),-- categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,++ categories: (await store.listCategories(db, space.id)).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v), can_manage_chat_room: canManageChatRoom(v, space) },children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),-- groups: moderator(v) ? store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })) : undefined,++ groups: moderator(v) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,threads: await shapeThreads(rows, () => space, v),};},@@@ -571,7 +571,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi*/async uploadAttachment(v, spaceSlug, file) {if (!media || !media.configured) fail(503, 'attachments.unavailable', 'Images cannot be attached right now');-- const space = spaceFor(v, spaceSlug);++ const space = await spaceFor(v, spaceSlug);if (!person(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account to attach images');mayPostIn(v, space);await requireMembership(v, space);@@@ -588,7 +588,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vifail(err.status === 413 || err.status === 415 ? err.status : 502, 'attachments.upload_failed', 'The image could not be stored. Try again.');}uploadLimiter.record(`s:${v.subject}`);-- db.prepare('INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)').run(stored.id, v.subject, filename, mime, buffer.length, stored.url);return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };},@@@ -600,7 +600,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi*/async react(v, postId, body = {}) {if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');-- const { post, thread, space } = postFor(v, postId);++ const { post, thread, space } = await postFor(v, postId);if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');await requireMembership(v, space, thread);if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');@@@ -608,9 +608,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);reactLimiter.check(`s:${v.subject}`);-- const mine = reactions.setReaction(db, post.id, v.subject, reaction);++ const mine = await reactions.setReaction(db, post.id, v.subject, reaction);reactLimiter.record(`s:${v.subject}`);-- const list = reactions.reactionsFor(db, [post.id], v.subject).get(post.id) || [];++ const list = (await reactions.reactionsFor(db, [post.id], v.subject)).get(post.id) || [];return { post_id: post.id, mine, reactions: list.map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, count: e.count, mine: e.mine })) };},@@@ -619,32 +619,32 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* quotes its opening; both show the link. Not members-only threads; not into roadmap spaces (staff excepted).*/async crosspost(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);const w = writer(v);if (!w.author) fail(403, 'crosspost.person_only', 'Only people crosspost');await requireMembership(v, space, thread);if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');-- const target = spaceFor(v, String(body.to || ''));++ const target = await spaceFor(v, String(body.to || ''));if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');mayPostIn(v, target);await requireMembership(v, target);const kind = target.thread_kind || 'discussion';if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff');-- if (store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);-- const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);++ if (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);++ const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;-- const { thread: created } = store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,++ const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });-- if (pulse) hook(() => pulse.threadCreated(created, target));-- if (relay) hook(() => relay.enqueueThread(created, target));++ if (pulse) await hook(async () => await pulse.threadCreated(created, target));++ if (relay) await hook(async () => await relay.enqueueThread(created, target));const projections = await authors.projectionsFor([created.author_subject]);-- return { thread: shapeThread(created, target, v, projections, null) };++ return { thread: await shapeThread(created, target, v, projections, null) };},/** Quote a post into a reply: "**name** wrote:" and the post as a Markdown quote. → { markdown } */async quote(v, postId) {-- const { post, thread, space } = postFor(v, postId);++ const { post, thread, space } = await postFor(v, postId);await requireMembership(v, space, thread);const projections = await authors.projectionsFor([post.author_subject]);const a = authors.author(post.author_subject, post.origin, projections, post.relay_author);@@@ -654,85 +654,85 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi},/** A page view of a thread (the forum's Views): once per viewer per 30 minutes. */-- recordView(threadId, viewerKey) {++ async recordView(threadId, viewerKey) {const key = `${threadId}|${viewerKey || '?'}`;const now = Date.now();const seen = viewSeen.get(key);if (seen && now - seen < 30 * 60 * 1000) return false;if (viewSeen.size > 50000) viewSeen.clear();viewSeen.set(key, now);-- store.bumpViews(db, threadId);++ await store.bumpViews(db, threadId);return true;},/** The categories of a space. */-- categories(v, spaceSlug) {-- const space = spaceFor(v, spaceSlug);-- return { space: space.slug, categories: store.listCategories(db, space.id).map(shapeCategory) };++ async categories(v, spaceSlug) {++ const space = await spaceFor(v, spaceSlug);++ return { space: space.slug, categories: (await store.listCategories(db, space.id)).map(shapeCategory) };},/** Create or change a category { slug, name, description?, position? } — moderators. */-- putCategory(v, spaceSlug, slug, body = {}) {++ async putCategory(v, spaceSlug, slug, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');-- const space = spaceFor(v, spaceSlug);++ const space = await spaceFor(v, spaceSlug);if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');const name = cleanTitle(body.name);if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;-- return { category: shapeCategory(store.upsertCategory(db, space.id, { slug, name, description, position })) };++ return { category: shapeCategory(await store.upsertCategory(db, space.id, { slug, name, description, position })) };},/** Delete a category — moderators. Its threads stay, without a category. */-- deleteCategory(v, spaceSlug, slug) {++ async deleteCategory(v, spaceSlug, slug) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');-- const space = spaceFor(v, spaceSlug);-- if (!store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');++ const space = await spaceFor(v, spaceSlug);++ if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');return { ok: true };},/** Move a thread to a category { category: slug | null } — its author or a moderator. */async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');await requireMembership(v, space, thread);-- const category = body.category ? store.getCategory(db, space.id, body.category) : null;++ const category = body.category ? await store.getCategory(db, space.id, body.category) : null;if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');-- const next = store.setThreadCategory(db, thread.id, category ? category.id : null);++ const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);const projections = await authors.projectionsFor([next.author_subject]);-- return { thread: shapeThread(next, space, v, projections, null) };++ return { thread: await shapeThread(next, space, v, projections, null) };},/** A request's or roadmap item's status { status } — moderators. */async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a status');-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);const statuses = STATUSES[thread.kind] || [];if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');-- const next = store.setThreadStatus(db, thread.id, body.status);++ const next = await store.setThreadStatus(db, thread.id, body.status);const projections = await authors.projectionsFor([next.author_subject]);-- return { thread: shapeThread(next, space, v, projections, null) };++ return { thread: await shapeThread(next, space, v, projections, null) };},/** A thread with a page of its posts. ?page= */async getThread(v, spaceSlug, threadSlug, q = {}) {-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);await requireMembership(v, space, thread);const page = Math.max(parseInt(q.page, 10) || 1, 1);-- const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });++ const { rows, total } = await store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);-- const votes = myVotes(db, 'thread', [thread.id], v && v.subject);++ const votes = await myVotes(db, 'thread', [thread.id], v && v.subject);const ids = rows.map((p) => p.id);-- const att = attachmentsOf(ids);-- const pasted = pastesOf(ids);-- const rated = space.reactions !== 0 ? reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();++ const att = await attachmentsOf(ids);++ const pasted = await pastesOf(ids);++ const rated = space.reactions !== 0 ? await reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();const authorSubjects = rows.map((p) => p.author_subject);-- const stats = store.authorStats(db, authorSubjects);-- const received = space.reactions !== 0 ? reactions.receivedBy(db, authorSubjects) : new Map();++ const stats = await store.authorStats(db, authorSubjects);++ const received = space.reactions !== 0 ? await reactions.receivedBy(db, authorSubjects) : new Map();const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));return {-- space: { ...shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...placeOf(space) },-- thread: shapeThread(thread, space, v, projections, votes),++ space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...await placeOf(space) },++ thread: await shapeThread(thread, space, v, projections, votes),posts: rows.map((p) => {const sp = shapePost(p, v, projections, att, pasted);const st = p.author_subject ? stats.get(p.author_subject) : null;@@@ -743,8 +743,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn sp;}),reactions: space.reactions !== 0 ? reactions.REACTIONS : [],-- crosspost: crosspostInfo(v, thread),-- categories: store.listCategories(db, space.id).map(shapeCategory),++ crosspost: await crosspostInfo(v, thread),++ categories: (await store.listCategories(db, space.id)).map(shapeCategory),attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,viewer: {@@@ -763,101 +763,101 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* the author's own VIP members; { owner } names the creator (moderators, or the author themselves).*/async createThread(v, spaceSlug, body = {}) {-- const space = spaceFor(v, spaceSlug);++ const space = await spaceFor(v, spaceSlug);const w = writer(v);mayPostIn(v, space);await requireMembership(v, space);const gate = gateOwner(v, body.members_only, w.author);const kind = space.thread_kind || 'discussion';if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');-- const category = body.category ? store.getCategory(db, space.id, body.category) : null;++ const category = body.category ? await store.getCategory(db, space.id, body.category) : null;if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');const title = cleanTitle(body.title);if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);const text = cleanBody(body.body != null ? body.body : body.body_markdown);-- const images = claimable(w, body.attachments);-- const pasteRows = claimPastes(body.pastes);++ const images = await claimable(w, body.attachments);++ const pasteRows = await claimPastes(body.pastes);if (w.key) {threadLimiter.check(w.key, title);-- if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);++ if (threadsPerDay > 0 && await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);}-- const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,++ const { thread, post } = await store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });threadLimiter.record(w.key, title);-- attach(images, post.id);-- attachPastes(pasteRows, post.id);-- if (pulse) hook(() => pulse.threadCreated(thread, space));-- if (relay) hook(() => relay.enqueueThread(thread, space));++ await attach(images, post.id);++ await attachPastes(pasteRows, post.id);++ if (pulse) await hook(async () => await pulse.threadCreated(thread, space));++ if (relay) await hook(async () => await relay.enqueueThread(thread, space));const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);-- return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])) };++ return { thread: await shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])) };},/** Reply { body } → { post } */async reply(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);const w = writer(v);mayPostIn(v, space);await requireMembership(v, space, thread);if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');// Platform blocks: nobody replies in a thread whose author blocked them.-- blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');++ await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');const text = cleanBody(body.body != null ? body.body : body.body_markdown);-- const images = claimable(w, body.attachments);-- const pasteRows = claimPastes(body.pastes);++ const images = await claimable(w, body.attachments);++ const pasteRows = await claimPastes(body.pastes);postLimiter.check(w.key, text);-- const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });++ const post = await store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });postLimiter.record(w.key, text);-- attach(images, post.id);-- attachPastes(pasteRows, post.id);-- if (pulse) hook(() => pulse.postCreated(post, thread, space));-- if (relay) hook(() => relay.enqueuePost(post, thread, space));++ await attach(images, post.id);++ await attachPastes(pasteRows, post.id);++ if (pulse) await hook(async () => await pulse.postCreated(post, thread, space));++ if (relay) await hook(async () => await relay.enqueuePost(post, thread, space));const projections = await authors.projectionsFor([post.author_subject]);// Where the new post lands: its page in the thread (posts are numbered in id order).-- const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;++ const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);-- return { post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };++ return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };},/** Edit { body } — the author (not on a locked thread) or a moderator. */async editPost(v, postId, body = {}) {-- const { post, thread, space } = postFor(v, postId);++ const { post, thread, space } = await postFor(v, postId);await requireMembership(v, space, thread);const mine = person(v) && post.author_subject === v.subject;if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');-- const next = store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);-- if (relay && next && next.revision !== post.revision) hook(() => relay.enqueueEdit(next));++ const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);++ if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));const projections = await authors.projectionsFor([next.author_subject]);return { post: shapePost(next, v, projections) };},/** Edit history — the author or a moderator. */async postVersions(v, postId) {-- const { post, thread, space } = postFor(v, postId);++ const { post, thread, space } = await postFor(v, postId);await requireMembership(v, space, thread);if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');-- const list = store.listPostVersions(db, post.id).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));++ const list = (await store.listPostVersions(db, post.id)).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };},/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */-- deletePost(v, postId) {-- const { post, thread } = postFor(v, postId);++ async deletePost(v, postId) {++ const { post, thread } = await postFor(v, postId);if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');-- if (post.is_opening) return removeThread(v, thread);-- db.transaction(() => {-- store.softDeletePost(db, post.id);-- if (!(person(v) && post.author_subject === v.subject)) events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });-- })();-- if (pulse) hook(() => pulse.postGone(post.id));-- if (relay) hook(() => relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));++ if (post.is_opening) return await removeThread(v, thread);++ await db.tx(async () => {++ await store.softDeletePost(db, post.id);++ if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });++ });++ if (pulse) await hook(async () => await pulse.postGone(post.id));++ if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));return { ok: true, id: post.id };},/** Delete a thread — its author or a moderator. */-- deleteThread(v, spaceSlug, threadSlug) {-- const { thread } = threadFor(v, spaceSlug, threadSlug);-- return removeThread(v, thread);++ async deleteThread(v, spaceSlug, threadSlug) {++ const { thread } = await threadFor(v, spaceSlug, threadSlug);++ return await removeThread(v, thread);},/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */@@@ -865,12 +865,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst value = parseVote(body.value);if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');await requireMembership(v, space, thread);if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');voteLimiter.check(`s:${v.subject}`);-- const out = applyVote(db, 'thread', thread.id, v.subject, value);++ const out = await applyVote(db, 'thread', thread.id, v.subject, value);voteLimiter.record(`s:${v.subject}`);return { thread_id: thread.id, ...out };},@@@ -878,18 +878,18 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi/** Pin / lock { pinned?, locked? } — moderators. */async moderateThread(v, spaceSlug, threadSlug, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);const flags = {};if (body.pinned !== undefined) flags.pinned = !!body.pinned;if (body.locked !== undefined) flags.locked = !!body.locked;if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');-- const next = db.transaction(() => {-- const r = store.setThreadFlags(db, thread.id, flags);-- if (flags.locked !== undefined && !!thread.locked !== flags.locked) events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });++ const next = await db.tx(async () => {++ const r = await store.setThreadFlags(db, thread.id, flags);++ if (flags.locked !== undefined && !!thread.locked !== flags.locked) await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });return r;-- })();++ });const projections = await authors.projectionsFor([next.author_subject]);-- return { thread: shapeThread(next, space, v, projections, null) };++ return { thread: await shapeThread(next, space, v, projections, null) };},/**@@@ -898,39 +898,39 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi* Pulse and cancels pending Discord relay deliveries.*/async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {-- const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);const mine = person(v) && thread.author_subject === v.subject;if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');await requireMembership(v, space, null);const requested = body.members_only !== undefined ? body.members_only : body.owner;const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject);if (!owner && thread.members_only_owner && !moderator(v) && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');-- const next = store.setThreadMembersOnly(db, thread.id, owner);-- if (owner) hideGated([thread.id]);++ const next = await store.setThreadMembersOnly(db, thread.id, owner);++ if (owner) await hideGated([thread.id]);const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);-- return { thread: shapeThread(next, space, v, projections, null) };++ return { thread: await shapeThread(next, space, v, projections, null) };},/** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */async setSpaceMembersOnly(v, spaceSlug, body = {}) {if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators make a space members-only');-- const space = spaceFor(v, spaceSlug);++ const space = await spaceFor(v, spaceSlug);const requested = body.members_only !== undefined ? body.members_only : body.owner;const owner = gateOwner(v, requested === undefined ? null : requested, null);-- const next = store.setSpaceMembersOnly(db, space.id, owner);-- if (owner) hideGated(db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id).map((r) => r.id));-- return { space: shapeSpace(next, await membersOnly(next.members_only_owner)) };++ const next = await store.setSpaceMembersOnly(db, space.id, owner);++ if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));++ return { space: await shapeSpace(next, await membersOnly(next.members_only_owner)) };},/** Latest threads in public spaces, with their opening post (sitemap, feeds). */-- recentPublic({ limit = 50, space = null } = {}) {-- const rows = store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });++ async recentPublic({ limit = 50, space = null } = {}) {++ const rows = await store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1');-- return rows.map((t) => ({ ...t, opening: (opening.get(t.id) || {}).body_markdown || '' }));++ return (await Promise.all(rows.map(async (t) => ({ ...t, opening: (await opening.get(t.id) || {}).body_markdown || '' }))));},/** Public, open spaces (sitemap, feeds): members-only spaces are left out. */-- publicSpaces() { return store.listSpaces(db, ['public']).filter((s) => !s.members_only_owner).map((s) => shapeSpace(s)); },++ async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },};}@@@ -13,8 +13,8 @@ const SORTS = ['hot', 'new', 'top', 'active'];// ── Spaces ───────────────────────────────────────────────────--function listSpaces(db, visibilities) {-- return db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,++async function listSpaces(db, visibilities) {++ return await db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,ps.slug AS parent_slug, ps.name AS parent_name,(SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,(SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count,@@@ -28,56 +28,56 @@ function listSpaces(db, visibilities) {* The newest live post in each space (the board index's "Last post"), members-only threads never.* → Map spaceId → { thread_slug, thread_title, author_subject, origin, created_at }*/--function lastPosts(db, spaceIds) {++async function lastPosts(db, spaceIds) {const out = new Map();const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_idFROM posts p JOIN threads t ON t.id = p.thread_idWHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULLORDER BY p.id DESC LIMIT 1`);-- for (const id of spaceIds) { const r = q.get(id); if (r) out.set(id, r); }++ for (const id of spaceIds) { const r = await q.get(id); if (r) out.set(id, r); }return out;}--function listGroups(db) {-- return db.prepare('SELECT * FROM space_groups ORDER BY position, name COLLATE NOCASE').all();++async function listGroups(db) {++ return await db.prepare('SELECT * FROM space_groups ORDER BY position, lower(name)').all();}/** Space settings (moderators): style, votes, reactions, group, parent, position, name, description. */--function updateSpace(db, id, fields) {++async function updateSpace(db, id, fields) {const allowed = ['style', 'votes', 'reactions', 'group_id', 'parent_id', 'position', 'name', 'description', 'thread_kind'];const keys = Object.keys(fields).filter((k) => allowed.includes(k));-- if (keys.length) db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });-- return getSpaceById(db, id);++ if (keys.length) await db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });++ return await getSpaceById(db, id);}--function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {-- db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)++async function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {++ await db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind);-- return getSpace(db, slug);++ return await getSpace(db, slug);}/** A person's forum statistics for the author panel. → Map subject → { posts, first_post_at } */--function authorStats(db, subjects) {++async function authorStats(db, subjects) {const out = new Map();const list = [...new Set(subjects.filter(Boolean))];if (!list.length) return out;-- const rows = db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts++ const rows = await db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM postsWHERE author_subject IN (${list.map(() => '?').join(', ')}) AND deleted_at IS NULL GROUP BY author_subject`).all(...list);for (const r of rows) out.set(r.s, { posts: r.posts, first_post_at: r.first_post_at });return out;}/** One more view of a thread (the forum's Views column). */--function bumpViews(db, threadId) {-- db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);++async function bumpViews(db, threadId) {++ await db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);}--function getSpace(db, slug) {-- return db.prepare('SELECT * FROM spaces WHERE slug = ?').get(String(slug)) || null;++async function getSpace(db, slug) {++ return await db.prepare('SELECT * FROM spaces WHERE slug = ?').get(String(slug)) || null;}--function getSpaceById(db, id) {-- return db.prepare('SELECT * FROM spaces WHERE id = ?').get(id) || null;++async function getSpaceById(db, id) {++ return await db.prepare('SELECT * FROM spaces WHERE id = ?').get(id) || null;}// ── Threads ──────────────────────────────────────────────────@@@ -89,39 +89,39 @@ function slugify(title) {return s || 'thread';}--function uniqueSlug(db, spaceId, base) {++async function uniqueSlug(db, spaceId, base) {const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');-- if (!taken.get(spaceId, base)) return base;++ if (!await taken.get(spaceId, base)) return base;for (let n = 2; n < 10_000; n++) {const candidate = `${base.slice(0, 54)}-${n}`;-- if (!taken.get(spaceId, candidate)) return candidate;++ if (!await taken.get(spaceId, candidate)) return candidate;}throw new Error('Could not find a free thread slug');}--function getThread(db, id) {-- return db.prepare('SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL').get(id) || null;++async function getThread(db, id) {++ return await db.prepare('SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL').get(id) || null;}--function getThreadBySlug(db, spaceId, slug) {-- return db.prepare('SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL').get(spaceId, String(slug)) || null;++async function getThreadBySlug(db, spaceId, slug) {++ return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL').get(spaceId, String(slug)) || null;}/** New thread + its opening post, in one transaction. → { thread, post } */--function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {-- return db.transaction(() => {-- const slug = uniqueSlug(db, space_id, slugify(title));-- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')++async function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {++ return await db.tx(async () => {++ const slug = await uniqueSlug(db, space_id, slugify(title));++ const info = await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id').run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);const threadId = info.lastInsertRowid;-- const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')++ const p = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?) RETURNING id').run(threadId, author_subject, origin, body_markdown);-- const thread = getThread(db, threadId);-- const space = db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);++ const thread = await getThread(db, threadId);++ const space = await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);// A members-only (VIP) space's threads are not public either.-- require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.thread.created-- return { thread, post: getPost(db, p.lastInsertRowid) };-- })();++ await require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.thread.created++ return { thread, post: await getPost(db, p.lastInsertRowid) };++ });}/**@@@ -131,7 +131,7 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us* top highest score first* → { rows, total }*/--function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {++async function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);const order = {hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',@@@ -139,128 +139,128 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =top: 'pinned DESC, score DESC, created_at DESC, id DESC',active: 'pinned DESC, last_activity_at DESC, id DESC',}[SORTS.includes(sort) ? sort : 'hot'];-- const where = 'space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)';++ const where = 'space_id = @space AND deleted_at IS NULL AND (@category::bigint IS NULL OR category_id = @category) AND (@status::text IS NULL OR status = @status)';const params = { space: spaceId, category: categoryId, status };// last_* : the newest live post, for the forum's "Last post" column.-- const rows = db.prepare(`SELECT threads.*,++ const rows = await db.prepare(`SELECT threads.*,(SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject,(SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin,(SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_idFROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`).all({ ...params, now: nowSql, limit, offset });-- const { total } = db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);++ const { total } = await db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);return { rows, total };}// ── Categories (WS-J task 1) ─────────────────────────────────--function listCategories(db, spaceId) {-- return db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count-- FROM categories c WHERE c.space_id = ? ORDER BY c.position, c.name COLLATE NOCASE`).all(spaceId);++async function listCategories(db, spaceId) {++ return await db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count++ FROM categories c WHERE c.space_id = ? ORDER BY c.position, lower(c.name)`).all(spaceId);}--function getCategory(db, spaceId, slug) {-- return db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;++async function getCategory(db, spaceId, slug) {++ return await db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;}--function getCategoryById(db, id) {-- return id ? db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;++async function getCategoryById(db, id) {++ return id ? await db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;}--function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {-- db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)++async function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {++ await db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`).run(spaceId, slug, name, description, position);-- return getCategory(db, spaceId, slug);++ return await getCategory(db, spaceId, slug);}/** Threads keep their place; they just lose the category. */--function deleteCategory(db, spaceId, slug) {-- return db.transaction(() => {-- const c = getCategory(db, spaceId, slug);++async function deleteCategory(db, spaceId, slug) {++ return await db.tx(async () => {++ const c = await getCategory(db, spaceId, slug);if (!c) return false;-- db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);-- db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);++ await db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);++ await db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);return true;-- })();++ });}--function setThreadCategory(db, id, categoryId) {-- db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);-- return getThread(db, id);++async function setThreadCategory(db, id, categoryId) {++ await db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);++ return await getThread(db, id);}--function setThreadStatus(db, id, status) {-- db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);-- return getThread(db, id);++async function setThreadStatus(db, id, status) {++ await db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);++ return await getThread(db, id);}--function getThreadByKey(db, spaceId, key) {-- return db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;++async function getThreadByKey(db, spaceId, key) {++ return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;}/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */--function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {-- return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id-- WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (? IS NULL OR s.slug = ?)++async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {++ return await db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id++ WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (?::text IS NULL OR s.slug = ?)AND s.members_only_owner IS NULL AND t.members_only_owner IS NULLORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);}--function setThreadFlags(db, id, { pinned, locked }) {++async function setThreadFlags(db, id, { pinned, locked }) {const sets = [], params = [];if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }if (locked !== undefined) { sets.push('locked = ?'); params.push(locked ? 1 : 0); }-- if (sets.length) db.prepare(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);-- return getThread(db, id);++ if (sets.length) await db.prepare(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);++ return await getThread(db, id);}/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */--function setThreadMembersOnly(db, id, owner) {-- db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);-- return getThread(db, id);++async function setThreadMembersOnly(db, id, owner) {++ await db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);++ return await getThread(db, id);}--function setSpaceMembersOnly(db, id, owner) {-- db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);-- return getSpaceById(db, id);++async function setSpaceMembersOnly(db, id, owner) {++ await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);++ return await getSpaceById(db, id);}--function softDeleteThread(db, id) {-- return db.prepare('UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(id).changes;++async function softDeleteThread(db, id) {++ return (await db.prepare('UPDATE threads SET deleted_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(id)).changes;}/** Threads a subject opened since `sinceSql` (SQLite modifier, e.g. '-1 day'); deleted ones count. */--function countThreadsSince(db, subject, sinceSql) {-- return db.prepare("SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;++async function countThreadsSince(db, subject, sinceSql) {++ return (await db.prepare("SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;}// ── Posts ────────────────────────────────────────────────────--function getPost(db, id) {-- return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) || null;++async function getPost(db, id) {++ return await db.prepare('SELECT * FROM posts WHERE id = ?').get(id) || null;}/*** A reply; bumps the thread's reply_count and last activity. relay_author: the name a reply written* on Discord carries (origin 'discord', no author subject; server/relay/inbound.js).*/--function addPost(db, { thread_id, author_subject = null, origin = 'user', body_markdown, relay_author = null }) {-- return db.transaction(() => {-- const info = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?)')++async function addPost(db, { thread_id, author_subject = null, origin = 'user', body_markdown, relay_author = null }) {++ return await db.tx(async () => {++ const info = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?) RETURNING id').run(thread_id, author_subject, origin, body_markdown, relay_author);-- db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);-- const post = getPost(db, info.lastInsertRowid);-- const thread = getThread(db, thread_id);-- const space = thread && db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);-- if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.post.created++ await db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = ov_now() WHERE id = ?').run(thread_id);++ const post = await getPost(db, info.lastInsertRowid);++ const thread = await getThread(db, thread_id);++ const space = thread && await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);++ if (thread) await require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.post.createdreturn post;-- })();++ });}/** Page of a thread's posts in order (tombstones included). → { rows, total } */--function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {-- const rows = db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?').all(threadId, limit, offset);-- const { total } = db.prepare('SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?').get(threadId);++async function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {++ const rows = await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?').all(threadId, limit, offset);++ const { total } = await db.prepare('SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?').get(threadId);return { rows, total };}@@@ -268,38 +268,38 @@ function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {* Edit a post: revision + 1 and a post_versions row. The first edit also snapshots the original* as revision 1, so the history is complete without copying posts nobody edits (as pastes do).*/--function editPost(db, id, body, editedBy = null) {-- return db.transaction(() => {-- const cur = getPost(db, id);++async function editPost(db, id, body, editedBy = null) {++ return await db.tx(async () => {++ const cur = await getPost(db, id);if (!cur || cur.deleted_at || cur.body_markdown === body) return cur;-- if (!db.prepare('SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?').get(id, cur.revision)) {-- db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)')++ if (!await db.prepare('SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?').get(id, cur.revision)) {++ await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)').run(id, cur.revision, cur.body_markdown, cur.author_subject, cur.updated_at || cur.created_at);}-- db.prepare('UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(body, id);-- const next = getPost(db, id);-- db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)').run(id, next.revision, body, editedBy);++ await db.prepare('UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = ov_now() WHERE id = ?').run(body, id);++ const next = await getPost(db, id);++ await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)').run(id, next.revision, body, editedBy);return next;-- })();++ });}--function listPostVersions(db, postId) {-- return db.prepare('SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC').all(postId);++async function listPostVersions(db, postId) {++ return await db.prepare('SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC').all(postId);}/** Soft delete (body scrubbed); the thread's reply_count is recounted. */--function softDeletePost(db, id) {-- return db.transaction(() => {-- const p = getPost(db, id);++async function softDeletePost(db, id) {++ return await db.tx(async () => {++ const p = await getPost(db, id);if (!p || p.deleted_at) return 0;-- db.prepare("UPDATE posts SET deleted_at = CURRENT_TIMESTAMP, body_markdown = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);-- db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?').run(p.thread_id, p.thread_id);++ await db.prepare("UPDATE posts SET deleted_at = ov_now(), body_markdown = '', updated_at = ov_now() WHERE id = ?").run(id);++ await db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?').run(p.thread_id, p.thread_id);return 1;-- })();++ });}--function countPostsSince(db, subject, sinceSql) {-- return db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;++async function countPostsSince(db, subject, sinceSql) {++ return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;}module.exports = {@@@ -24,20 +24,10 @@ const TOPICS = ['network.account.export_requested', 'network.account.deleted'];const TOMBSTONE = '[deleted]';const ROW_LIMIT = 5000;--const hasTable = (db, t) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);--const hasColumn = (db, table, col) => { try { return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col); } catch { return false; } };++const hasTable = async (db, t) => !!await db.prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?').get(t);++const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);const inList = (xs) => `(${xs.map(() => '?').join(',')})`;--function ensureSchema(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (-- id TEXT PRIMARY KEY,-- kind TEXT NOT NULL,-- subject TEXT NOT NULL,-- outcome TEXT,-- sent_at TEXT,-- applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))-- )`);--}// ── Export ─────────────────────────────────────────────────────@@@ -49,12 +39,14 @@ const EXPORTS = [['activity.json', 'pulse_items', 'actor_subject'],];--function exportPart(db, subject) {++async function exportPart(db, subject) {const files = [];const truncated = [];for (const [name, table, col] of EXPORTS) {-- if (!hasColumn(db, table, col)) continue;-- const rows = db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);++ if (!await hasColumn(db, table, col)) continue;++ // Newest first: by id where the table has one, else by its timestamp.++ const order = await hasColumn(db, table, 'id') ? 'id' : await hasColumn(db, table, 'created_at') ? 'created_at' : 'updated_at';++ const rows = await db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY ${order} DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);if (!rows.length) continue;if (rows.length > ROW_LIMIT) truncated.push(name);files.push({ name, content: rows.slice(0, ROW_LIMIT) });@@@ -65,42 +57,42 @@ function exportPart(db, subject) {// ── Deletion ───────────────────────────────────────────────────/** Ids of the rows in `table` authored by the subjects, split into those to delete and those with others' replies. */--function splitByReplies(db, table, subjects) {-- const mine = db.prepare(`SELECT id FROM ${table} WHERE author_subject IN ${inList(subjects)}`).all(...subjects).map((r) => r.id);++async function splitByReplies(db, table, subjects) {++ const mine = (await db.prepare(`SELECT id FROM ${table} WHERE author_subject IN ${inList(subjects)}`).all(...subjects)).map((r) => r.id);const keep = [];const drop = [];for (const id of mine) {-- const other = db.prepare(`WITH RECURSIVE d(id) AS (SELECT id FROM ${table} WHERE parent_id = ? UNION ALL SELECT c.id FROM ${table} c JOIN d ON c.parent_id = d.id)++ const other = await db.prepare(`WITH RECURSIVE d(id) AS (SELECT id FROM ${table} WHERE parent_id = ? UNION ALL SELECT c.id FROM ${table} c JOIN d ON c.parent_id = d.id)SELECT 1 FROM ${table} WHERE id IN (SELECT id FROM d) AND (author_subject IS NULL OR author_subject NOT IN ${inList(subjects)}) LIMIT 1`).get(id, ...subjects);(other ? keep : drop).push(id);}return { keep, drop };}--function erase(db, subjects, { now = new Date().toISOString() } = {}) {++async function erase(db, subjects, { now = new Date().toISOString() } = {}) {const erased = {};const retained = {};const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };const S = inList(subjects);-- db.transaction(() => {++ await db.tx(async () => {// Likes, votes and reactions first, so the counts recomputed below see only what stays.const recount = [];for (const [table, item] of [['paste_likes', 'paste_id'], ['comment_votes', 'comment_id'], ['thread_votes', 'thread_id'], ['post_reactions', 'post_id']]) {-- if (!hasTable(db, table)) continue;-- const items = db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects).map((r) => r.i);-- add(erased, table, db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects).changes);++ if (!await hasTable(db, table)) continue;++ const items = (await db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects)).map((r) => r.i);++ add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);recount.push([table, items]);}// Pastes: gone, unless someone else commented.-- if (hasTable(db, 'pastes')) {-- for (const p of db.prepare(`SELECT id FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {-- const others = db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);++ if (await hasTable(db, 'pastes')) {++ for (const p of await db.prepare(`SELECT id FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {++ const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);if (others) {-- db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,++ await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,ai_summary = NULL, ai_tags = NULL, legacy_user_id = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);add(retained, 'tombstones', 1);} else {-- db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);++ await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);add(erased, 'pastes', 1);}}@@@ -108,50 +100,50 @@ function erase(db, subjects, { now = new Date().toISOString() } = {}) {// Comments on pastes and on threads (VODs, clips, pages): gone, unless someone else answered beneath.for (const [table, tomb] of [['paste_comments', 'author_subject = NULL, anon_name = NULL, message = ?, is_deleted = 1, updated_at = ?'],['comments', 'author_subject = NULL, anon_name = NULL, message = ?, deleted_at = COALESCE(deleted_at, ?)']]) {-- if (!hasTable(db, table)) continue;-- const { keep, drop } = splitByReplies(db, table, subjects);-- for (const id of keep) db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);-- for (const id of drop) db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);++ if (!await hasTable(db, table)) continue;++ const { keep, drop } = await splitByReplies(db, table, subjects);++ for (const id of keep) await db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);++ for (const id of drop) await db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);add(erased, table, drop.length);add(retained, 'tombstones', keep.length);-- if (table === 'comments') db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id) WHERE reply_count != (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id)').run();++ if (table === 'comments') await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id) WHERE reply_count != (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id)').run();}// Threads: gone with their posts, unless someone else posted; then the thread and its opening post are tombstones.-- if (hasTable(db, 'threads')) {-- for (const t of db.prepare(`SELECT id FROM threads WHERE author_subject IN ${S}`).all(...subjects)) {-- const others = db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(t.id, ...subjects);-- if (others) { db.prepare('UPDATE threads SET author_subject = NULL, title = ? WHERE id = ?').run(TOMBSTONE, t.id); add(retained, 'tombstones', 1); }-- else { db.prepare('DELETE FROM threads WHERE id = ?').run(t.id); add(erased, 'threads', 1); }++ if (await hasTable(db, 'threads')) {++ for (const t of await db.prepare(`SELECT id FROM threads WHERE author_subject IN ${S}`).all(...subjects)) {++ const others = await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(t.id, ...subjects);++ if (others) { await db.prepare('UPDATE threads SET author_subject = NULL, title = ? WHERE id = ?').run(TOMBSTONE, t.id); add(retained, 'tombstones', 1); }++ else { await db.prepare('DELETE FROM threads WHERE id = ?').run(t.id); add(erased, 'threads', 1); }}}-- if (hasTable(db, 'posts')) {-- for (const p of db.prepare(`SELECT id, thread_id, is_opening FROM posts WHERE author_subject IN ${S}`).all(...subjects)) {-- const others = p.is_opening && db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND id != ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.thread_id, p.id, ...subjects);-- if (others) { db.prepare('UPDATE posts SET author_subject = NULL, relay_author = NULL, body_markdown = ?, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?').run(TOMBSTONE, now, now, p.id); add(retained, 'tombstones', 1); }-- else { db.prepare('DELETE FROM posts WHERE id = ?').run(p.id); add(erased, 'posts', 1); }++ if (await hasTable(db, 'posts')) {++ for (const p of await db.prepare(`SELECT id, thread_id, is_opening FROM posts WHERE author_subject IN ${S}`).all(...subjects)) {++ const others = p.is_opening && await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND id != ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.thread_id, p.id, ...subjects);++ if (others) { await db.prepare('UPDATE posts SET author_subject = NULL, relay_author = NULL, body_markdown = ?, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?').run(TOMBSTONE, now, now, p.id); add(retained, 'tombstones', 1); }++ else { await db.prepare('DELETE FROM posts WHERE id = ?').run(p.id); add(erased, 'posts', 1); }}-- if (hasColumn(db, 'threads', 'reply_count')) db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();++ if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();}-- if (hasTable(db, 'attachments')) add(erased, 'attachments', db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects).changes);++ if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {-- if (!hasTable(db, table)) continue;++ if (!await hasTable(db, table)) continue;const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;-- add(erased, key, db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes);++ add(erased, key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes);}-- for (const table of ['spaces', 'comment_threads']) if (hasColumn(db, table, 'created_by')) db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);++ for (const table of ['spaces', 'comment_threads']) if (await hasColumn(db, table, 'created_by')) await db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);// The cached counts and scores follow the rows that stay.for (const [table, items] of recount) {for (const i of items) {-- if (table === 'paste_likes') db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(i, i);++ if (table === 'paste_likes') await db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(i, i);else if (table === 'comment_votes') {-- const a = db.prepare('SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS u, COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS d FROM comment_votes WHERE comment_id = ?').get(i);-- db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, i);-- } else if (table === 'thread_votes') db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0) FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(i, i);++ const a = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s, COUNT(*) FILTER (WHERE value = 1) AS u, COUNT(*) FILTER (WHERE value = -1) AS d FROM comment_votes WHERE comment_id = ?').get(i);++ await db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, i);++ } else if (table === 'thread_votes') await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(i, i);}}-- })();++ });return { erased, retained };}@@@ -162,7 +154,7 @@ function createSender({ config, fetchImpl = globalThis.fetch } = {}) {const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.network', fetchImpl });return async function send(path, body, retried = false) {const res = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });-- if (res.status === 401 && !retried) { tokens.invalidate(); return send(path, body, true); }++ if (res.status === 401 && !retried) { tokens.invalidate(); return await send(path, body, true); }return res;};}@@@ -172,36 +164,36 @@ async function apply(db, ev, { send, log = console } = {}) {if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';if (ev.source !== 'network') return 'ignored:source';const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};-- ensureSchema(db);if (ev.event_type === 'network.account.export_requested') {if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';-- const seen = db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);++ const seen = await db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);if (seen && seen.sent_at) return 'unchanged';-- const part = exportPart(db, p.subject);++ const part = await exportPart(db, p.subject);const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);if (!outcome) throw new Error(`export part refused: ${res.status}`);-- db.prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')++ await db.prepare(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject, outcome = excluded.outcome, sent_at = excluded.sent_at, applied_at = ov_now_iso()`).run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());return outcome;}if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';-- let rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);++ let rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);let result = 'confirmed';if (!rec) {const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];-- const counts = erase(db, subjects);-- db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));++ const counts = await erase(db, subjects);++ await db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);-- rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);++ rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);result = 'erased';}if (rec.sent_at) return 'unchanged';const o = JSON.parse(rec.outcome || '{}');const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);-- db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);++ await db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);return result;}--module.exports = { apply, exportPart, erase, ensureSchema, createSender, TOPICS };++module.exports = { apply, exportPart, erase, createSender, TOPICS };@@@ -14,8 +14,8 @@ function createAuthors({ db, network = null }) {async function projectionsFor(subjects) {const list = (subjects || []).filter(Boolean);if (!list.length) return new Map();-- if (!network) return pasteStore.getProjections(db, list);-- try { return await network.projections(list); } catch { return pasteStore.getProjections(db, list); }++ if (!network) return await pasteStore.getProjections(db, list);++ try { return await network.projections(list); } catch { return await pasteStore.getProjections(db, list); }}/**@@@ -15,19 +15,6 @@ const { fail } = require('../http/v1');const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const ready = new WeakSet();--function ensureSchema(db) {-- if (ready.has(db)) return;-- db.exec(`CREATE TABLE IF NOT EXISTS network_blocks (-- blocker_subject TEXT NOT NULL,-- blocked_subject TEXT NOT NULL,-- active INTEGER NOT NULL,-- revision INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- PRIMARY KEY (blocker_subject, blocked_subject)-- );-- CREATE INDEX IF NOT EXISTS idx_network_blocks_blocked ON network_blocks(blocked_subject, active);`);-- ready.add(db);--}/** The payload of a network.block.changed envelope from Network, or an 'ignored:*' reason. */function payloadOf(event) {@@@ -38,9 +25,8 @@ function payloadOf(event) {}/** Apply one change when it is newer than what is kept for its pair. → 'blocks:blocked' | 'blocks:unblocked' | 'blocks:unchanged' */--function apply(db, p, now = Date.now()) {-- ensureSchema(db);-- const r = db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)++async function apply(db, p, now = Date.now()) {++ const r = await db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_atWHERE excluded.revision > network_blocks.revision`).run(p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now);if (!r.changes) return 'blocks:unchanged';@@@ -48,21 +34,20 @@ function apply(db, p, now = Date.now()) {}/** Did `blocker` block `blocked` (subjects)? */--function hasBlocked(db, blocker, blocked) {++async function hasBlocked(db, blocker, blocked) {if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || '')) || blocker === blocked) return false;-- ensureSchema(db);-- return !!db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);++ return !!await db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);}/*** Refuse (403 community.blocked) when any of `owners` blocked `writer`. `what` names the thing in the* message: "You cannot <what>: its author blocked you".*/--function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {++async function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {if (!writer) return;for (const owner of owners) {-- if (hasBlocked(db, owner, writer)) fail(403, 'community.blocked', `You cannot ${what}: ${whose} blocked you`);++ if (await hasBlocked(db, owner, writer)) fail(403, 'community.blocked', `You cannot ${what}: ${whose} blocked you`);}}--module.exports = { ensureSchema, payloadOf, apply, hasBlocked, refuseIfBlocked };++module.exports = { payloadOf, apply, hasBlocked, refuseIfBlocked };@@@ -42,7 +42,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}signal: AbortSignal.timeout(8000),});// A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.-- if (res.status === 401 && !retried) { tokens.invalidate(); return post(body, true); }++ if (res.status === 401 && !retried) { tokens.invalidate(); return await post(body, true); }const data = await res.json().catch(() => null);if (!res.ok || !data || typeof data.results !== 'object') {const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);@@@ -65,9 +65,9 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}}/** Store a Network projection in the cache. */-- function remember(p) {++ async function remember(p) {if (!p || !p.subject || !p.subject.id) return;-- store.upsertProjection(db, {++ await store.upsertProjection(db, {subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,});@@@ -76,14 +76,14 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}/** Subject ids → projections (and cache them). */async function resolveSubjects(subjectIds) {const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));-- for (const p of map.values()) remember(p);++ for (const p of map.values()) await remember(p);return map;}/** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */async function resolveLegacy(system, ids, { cache = true } = {}) {const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));-- if (cache) for (const p of map.values()) remember(p);++ if (cache) for (const p of map.values()) await remember(p);return map;}@@@ -93,19 +93,19 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}*/async function subjectForNetworkUser(networkUserId) {if (networkUserId == null || networkUserId === '') return null;-- const hit = store.mapGet(db, 'network', 'user', networkUserId);++ const hit = await store.mapGet(db, 'network', 'user', networkUserId);if (hit && hit.target_type === 'subject') return hit.target_id;const map = await resolveLegacy('network', [networkUserId]);const p = map.get(String(networkUserId));if (!p || !p.subject || p.subject.type !== 'user') return null;-- store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);++ await store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);return p.subject.id;}/** What a verified user JWT says about its holder is a fresh projection too. */-- function rememberClaims(subjectId, claims) {++ async function rememberClaims(subjectId, claims) {if (!subjectId || !claims) return;-- store.upsertProjection(db, {++ await store.upsertProjection(db, {subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,});@@@ -133,7 +133,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}async function projections(subjectIds) {const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];if (!ids.length) return new Map();-- let map = store.getProjections(db, ids);++ let map = await store.getProjections(db, ids);const missing = ids.filter((id) => !map.has(id));const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);if (stale.length) refreshLater(stale);@@@ -144,7 +144,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));await Promise.race([lookup, wait]);clearTimeout(timer);-- map = store.getProjections(db, ids);++ map = await store.getProjections(db, ids);}return map;}@@@ -24,21 +24,14 @@ const toIso = (v) => {};const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);--function ensureSchema(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS profile_module_pushes (-- subject_id TEXT PRIMARY KEY,-- hash TEXT NOT NULL,-- pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP-- )`);--}/** The record for one person, or null when they wrote nothing. */--function summarize(db, subject) {-- const one = (sql) => db.prepare(sql).get(subject);-- const threads = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM threads WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");-- const posts = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM posts WHERE author_subject = ? AND deleted_at IS NULL AND is_opening = 0 AND origin = 'user'");-- const comments = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM comments WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");-- const pastes = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM pastes WHERE owner_subject = ? AND deleted_at IS NULL AND origin = 'user' AND visibility = 'public'");++async function summarize(db, subject) {++ const one = async (sql) => await db.prepare(sql).get(subject);++ const threads = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM threads WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");++ const posts = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM posts WHERE author_subject = ? AND deleted_at IS NULL AND is_opening = 0 AND origin = 'user'");++ const comments = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM comments WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");++ const pastes = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM pastes WHERE owner_subject = ? AND deleted_at IS NULL AND origin = 'user' AND visibility = 'public'");const all = [threads, posts, comments, pastes];if (!all.some((r) => r.n)) return null;const firsts = all.map((r) => r.first).filter(Boolean).sort();@@@ -70,13 +63,13 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c/** Write one person's record when it changed. → true when written */async function push(subject) {if (!enabled || !USR.test(String(subject || ''))) return false;-- const data = summarize(db, subject);++ const data = await summarize(db, subject);if (!data) return false;const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);-- const last = db.prepare('SELECT hash FROM profile_module_pushes WHERE subject_id = ?').get(subject);++ const last = await db.prepare('SELECT hash FROM profile_module_pushes WHERE subject_id = ?').get(subject);if (last && last.hash === hash) { stats.unchanged++; return false; }try { await client().put(NS, subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; return false; }-- db.prepare(`INSERT INTO profile_module_pushes (subject_id, hash, pushed_at) VALUES (?, ?, CURRENT_TIMESTAMP)++ await db.prepare(`INSERT INTO profile_module_pushes (subject_id, hash, pushed_at) VALUES (?, ?, ov_now())ON CONFLICT(subject_id) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash);stats.written++;return true;@@@ -94,7 +87,7 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = cchanged('comments', 'author_subject', ['created_at', 'updated_at', 'deleted_at']),changed('pastes', 'owner_subject', ['created_at', 'updated_at', 'deleted_at']),].join(' UNION ');-- const subjects = db.prepare(sql).all({ from, to }).map((r) => r.s).filter((s) => USR.test(String(s || '')));++ const subjects = (await db.prepare(sql).all({ from, to })).map((r) => r.s).filter((s) => USR.test(String(s || '')));for (const s of subjects) { if (stopped) return subjects.length; await push(s); }lastScan = to;return subjects.length;@@@ -105,7 +98,6 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = cfunction start() {if (!enabled || timer) return false;stopped = false;-- ensureSchema(db);timer = setInterval(() => {if (running) return;running = scan().catch((err) => { stats.lastError = err.message; }).finally(() => { running = null; });@@@ -120,4 +112,4 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = creturn { enabled, push, scan, start, stop, stats: () => ({ enabled, ...stats }) };}--module.exports = { NS, ensureSchema, summarize, createProfileModule };++module.exports = { NS, summarize, createProfileModule };@@@ -17,16 +17,16 @@ const AUTHORSHIP = [];// Per person per item: [table, item column, recompute(db, itemId)]const PER_ITEM = [-- ['paste_likes', 'paste_id', (db, id) => db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(id, id)],-- ['comment_votes', 'comment_id', (db, id) => {-- const a = db.prepare('SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS u, COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS d FROM comment_votes WHERE comment_id = ?').get(id);-- db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, id);++ ['paste_likes', 'paste_id', async (db, id) => await db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(id, id)],++ ['comment_votes', 'comment_id', async (db, id) => {++ const a = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s, COUNT(*) FILTER (WHERE value = 1) AS u, COUNT(*) FILTER (WHERE value = -1) AS d FROM comment_votes WHERE comment_id = ?').get(id);++ await db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, id);}],-- ['thread_votes', 'thread_id', (db, id) => db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0) FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(id, id)],++ ['thread_votes', 'thread_id', async (db, id) => await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(id, id)],['post_reactions', 'post_id', null],];--const hasColumn = (db, table, col) => { try { return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col); } catch { return false; } };++const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);/** The payload, or 'ignored:<why>'. */function payloadOf(event) {@@@ -38,35 +38,35 @@ function payloadOf(event) {}/** Apply one merge → 'merge:applied' (with counts logged). */--function apply(db, { from, into, merge_id: mergeId }, { log = console } = {}) {-- const counts = db.transaction(() => {++async function apply(db, { from, into, merge_id: mergeId }, { log = console } = {}) {++ const counts = await db.tx(async () => {const c = { authored: 0, moved: 0, dropped: 0 };for (const [table, col] of AUTHORSHIP) {-- if (hasColumn(db, table, col)) c.authored += db.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`).run(into, from).changes;++ if (await hasColumn(db, table, col)) c.authored += (await db.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`).run(into, from)).changes;}for (const [table, item, recompute] of PER_ITEM) {-- if (!hasColumn(db, table, 'subject_id')) continue;-- for (const r of db.prepare(`SELECT ${item} AS i FROM ${table} WHERE subject_id = ?`).all(from)) {-- const clash = db.prepare(`SELECT 1 FROM ${table} WHERE ${item} = ? AND subject_id = ?`).get(r.i, into);-- if (clash) { db.prepare(`DELETE FROM ${table} WHERE ${item} = ? AND subject_id = ?`).run(r.i, from); c.dropped++; if (recompute) recompute(db, r.i); }-- else { db.prepare(`UPDATE ${table} SET subject_id = ? WHERE ${item} = ? AND subject_id = ?`).run(into, r.i, from); c.moved++; }++ if (!await hasColumn(db, table, 'subject_id')) continue;++ for (const r of await db.prepare(`SELECT ${item} AS i FROM ${table} WHERE subject_id = ?`).all(from)) {++ const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${item} = ? AND subject_id = ?`).get(r.i, into);++ if (clash) { await db.prepare(`DELETE FROM ${table} WHERE ${item} = ? AND subject_id = ?`).run(r.i, from); c.dropped++; if (recompute) recompute(db, r.i); }++ else { await db.prepare(`UPDATE ${table} SET subject_id = ? WHERE ${item} = ? AND subject_id = ?`).run(into, r.i, from); c.moved++; }}}-- if (hasColumn(db, 'game_progress', 'subject_id')) {-- if (db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);-- else db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);++ if (await hasColumn(db, 'game_progress', 'subject_id')) {++ if (await db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) await db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);++ else await db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);}-- if (hasColumn(db, 'network_blocks', 'blocker_subject')) {++ if (await hasColumn(db, 'network_blocks', 'blocker_subject')) {for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {-- for (const b of db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {-- const clash = b.o === into || db.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);-- if (clash) db.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);-- else db.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);++ for (const b of await db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {++ const clash = b.o === into || await db.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);++ if (clash) await db.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);++ else await db.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);}}}return c;-- })();++ });log.log(`[Merge] ${mergeId}: ${JSON.stringify(counts)}`);return 'merge:applied';}@@@ -112,7 +112,7 @@ function createViewerResolver({ auth, config, network, revocations = null }) {if (!subject && network && claims.sub != null) {try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }}-- if (subject && network) { try { network.rememberClaims(subject, claims); } catch { /* display cache only */ } }++ if (subject && network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }const user = claimsToUser(claims);if (subject && !user.subject_id) user.subject_id = subject;// Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.@@@ -132,7 +132,7 @@ function createViewerResolver({ auth, config, network, revocations = null }) {const payload = decodeJwtPayload(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);}}const token = extractToken(req);@@@ -7,69 +7,77 @@const config = require('./config');const { createApp } = require('./app');--const app = createApp();--if (config.discordRelay.enabled) {-- const rs = app.locals.relay.status();-- const part = (x) => (x.enabled ? 'on' : `off (${x.reason})`);-- console.log(`[Relay] Discord relay on: creates queued by the ${rs.creates_from === 'events' ? 'Events worker' : 'forum'}; events worker ${part(rs.events_worker)}; inbound ${part(rs.inbound)}`);--}--// Community → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.--try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }--// The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).--try {-- const r = require('./forum/roadmap').syncFromFile(require('./db').getDb());-- if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);--} catch (err) { console.warn('[Roadmap] not synced:', err.message); }--const server = app.listen(config.port, config.host, () => {-- console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);-- console.log(config.pastesAuthority === 'community'-- ? `[Community] pastes: this site is the authority (${config.dbPath}), identity via ${config.networkUrl}`-- : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);--});--server.keepAliveTimeout = 65_000;--// Subscribe Pulse to public activity at Events (idempotent; off without EVENTS_URL / COMMUNITY_EVENTS_SECRET).--let subscriptions = null;--try {-- const secret = String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',')[0].trim();-- subscriptions = require('./pulse/consumer').startSubscriptions({ config, port: config.port, secret });--} catch (err) { console.warn('[Pulse consumer] not subscribed:', err.message); }--// community.profile on Network (Contracts 0.41.0): a 5-minute scan of changed authors (off without the client secret).--let profiles = null;--try { profiles = require('./identity/profile-module').createProfileModule({ db: require('./db').getDb(), config }); profiles.start(); } catch (err) { console.warn('[Modules] community.profile not started:', err.message); }--// Public threads and pastes in OpenVibe.Search (WS-O task 10): community.index_document.* through the outbox (off without EVENTS_URL).--let searchDocs = null;--try { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }++(async () => {++ // PostgreSQL first (migrations run as the owner), then the app: its revocation cutoffs load before it serves.++ await require('./db').initDb(config);++ const app = await createApp();++ if (config.discordRelay.enabled) {++ const rs = await app.locals.relay.status();++ const part = (x) => (x.enabled ? 'on' : `off (${x.reason})`);++ console.log(`[Relay] Discord relay on: creates queued by the ${rs.creates_from === 'events' ? 'Events worker' : 'forum'}; events worker ${part(rs.events_worker)}; inbound ${part(rs.inbound)}`);++ }++ // Community → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.++ try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }++ // The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).++ try {++ const r = await require('./forum/roadmap').syncFromFile(require('./db').getDb());++ if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);++ } catch (err) { console.warn('[Roadmap] not synced:', err.message); }++ const server = app.listen(config.port, config.host, () => {++ console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);++ console.log(config.pastesAuthority === 'community'++ ? `[Community] pastes: this site is the authority (${config.dbPath}), identity via ${config.networkUrl}`++ : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);++ });++ server.keepAliveTimeout = 65_000;++ // Subscribe Pulse to public activity at Events (idempotent; off without EVENTS_URL / COMMUNITY_EVENTS_SECRET).++ let subscriptions = null;++ try {++ const secret = String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',')[0].trim();++ subscriptions = require('./pulse/consumer').startSubscriptions({ config, port: config.port, secret });++ } catch (err) { console.warn('[Pulse consumer] not subscribed:', err.message); }++ // community.profile on Network (Contracts 0.41.0): a 5-minute scan of changed authors (off without the client secret).++ let profiles = null;++ try { profiles = require('./identity/profile-module').createProfileModule({ db: require('./db').getDb(), config }); profiles.start(); } catch (err) { console.warn('[Modules] community.profile not started:', err.message); }++ // Public threads and pastes in OpenVibe.Search (WS-O task 10): community.index_document.* through the outbox (off without EVENTS_URL).++ let searchDocs = null;++ try { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }--// ── Stop (roadmap WS-P lifecycle; server/graceful.js) ────────--// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans--// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps--// them 65 s otherwise) and lets requests in flight finish (4 s at most); then the profile scan in--// progress, the Discord relay's drain and its Events worker's page (the gateway closes at once) and the--// events outbox's send finish (unsent rows stay in their tables for the next start), community.db--// closes, and the process exits 0, within the manifest's 5 s.--const { within } = require('./graceful');--let profilesDone = null;--require('./graceful').gracefulStop({-- name: 'Community', server,-- stop: [-- () => { if (subscriptions) subscriptions.stop(); },-- () => { if (profiles) profilesDone = profiles.stop(); },-- () => { if (searchDocs) searchDocs.stop(); },-- ],-- close: [-- () => within(1000, profilesDone),-- () => within(1000, app.locals.relay && app.locals.relay.stop()),-- () => within(1500, require('./events').stop()),-- () => {-- const ev = require('./events').status();-- let relay = 'off';-- if (config.discordRelay.enabled) {-- let rs = null;-- try { rs = app.locals.relay.status(); } catch { /* the database is gone */ }-- relay = rs ? `stopped (events worker ${rs.events_worker.enabled ? 'stopped' : 'off'}, gateway ${rs.inbound.enabled ? 'stopped' : 'off'})` : 'stopped';-- }-- console.log(`[Community] stopped: subscriptions ${subscriptions ? 'stopped' : 'off'}, profile scan ${profiles && profiles.enabled ? 'stopped' : 'off'}, search scans ${searchDocs ? 'stopped' : 'off'}, relay ${relay}, outbox ${ev.enabled ? `stopped (${ev.pending} pending)` : 'off'}`);-- require('./db').closeDb();-- },-- ],++ // ── Stop (roadmap WS-P lifecycle; server/graceful.js) ────────++ // SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans++ // stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps++ // them 65 s otherwise) and lets requests in flight finish (4 s at most); then the profile scan in++ // progress, the Discord relay's drain and its Events worker's page (the gateway closes at once) and the++ // events outbox's send finish (unsent rows stay in their tables for the next start), community.db++ // closes, and the process exits 0, within the manifest's 5 s.++ const { within } = require('./graceful');++ let profilesDone = null;++ require('./graceful').gracefulStop({++ name: 'Community', server,++ stop: [++ () => { if (subscriptions) subscriptions.stop(); },++ () => { if (profiles) profilesDone = profiles.stop(); },++ () => { if (searchDocs) searchDocs.stop(); },++ ],++ close: [++ () => within(1000, profilesDone),++ () => within(1000, app.locals.relay && app.locals.relay.stop()),++ () => within(1500, require('./events').stop()),++ async () => {++ const ev = await require('./events').backlog().catch(() => require('./events').status());++ let relay = 'off';++ if (config.discordRelay.enabled) {++ let rs = null;++ try { rs = await app.locals.relay.status(); } catch { /* the database is gone */ }++ relay = rs ? `stopped (events worker ${rs.events_worker.enabled ? 'stopped' : 'off'}, gateway ${rs.inbound.enabled ? 'stopped' : 'off'})` : 'stopped';++ }++ console.log(`[Community] stopped: subscriptions ${subscriptions ? 'stopped' : 'off'}, profile scan ${profiles && profiles.enabled ? 'stopped' : 'off'}, search scans ${searchDocs ? 'stopped' : 'off'}, relay ${relay}, outbox ${ev.enabled ? `stopped (${ev.pending} pending)` : 'off'}`);++ if (app.locals.valkey) app.locals.valkey.close().catch(() => {});++ return await require('./db').closeDb();++ },++ ],++ });++})().catch((err) => {++ console.error('[Community] failed to start:', err);++ process.exit(1);});@@@ -60,8 +60,8 @@ async function request(apiPath, { method = 'GET', query, body, token, ip, timeou// ── Pastes ───────────────────────────────────────────────────/** Public listing: { pastes, total, limit, offset }. */--function listPastes(query, ctx = {}) {-- return request('/pastes', { query, token: ctx.token, ip: ctx.ip });++async function listPastes(query, ctx = {}) {++ return await request('/pastes', { query, token: ctx.token, ip: ctx.ip });}/** One paste (counts a view unless noView). Throws LiveApiError(404) when unknown/private. */@@@ -73,13 +73,13 @@ async function getPaste(slug, ctx = {}) {}/** A user's pastes ({ pastes, total, username }); the owner also gets unlisted/private ones. */--function listByUser(username, query, ctx = {}) {-- return request(`/pastes/by-user/${encodeURIComponent(username)}`, { query, token: ctx.token, ip: ctx.ip });++async function listByUser(username, query, ctx = {}) {++ return await request(`/pastes/by-user/${encodeURIComponent(username)}`, { query, token: ctx.token, ip: ctx.ip });}/** Create a text paste on the visitor's behalf (no-JS form fallback). */--function createPaste(body, ctx = {}) {-- return request('/pastes', { method: 'POST', body, token: ctx.token, ip: ctx.ip, timeoutMs: 20_000 });++async function createPaste(body, ctx = {}) {++ return await request('/pastes', { method: 'POST', body, token: ctx.token, ip: ctx.ip, timeoutMs: 20_000 });}// ── Public URL builders (OpenVibe.Media serves the bytes) ────@@@ -32,7 +32,7 @@ function createMediaFiles({ config, fetchImpl = globalThis.fetch } = {}) {body: fd,signal: AbortSignal.timeout(60_000),});-- if (res.status === 401 && !retried) { tokens.invalidate(); return upload({ buffer, filename, mime }, true); }++ if (res.status === 401 && !retried) { tokens.invalidate(); return await upload({ buffer, filename, mime }, true); }const data = await res.json().catch(() => null);if (!res.ok || !data || !data.key || !data.url) {const err = new Error(`Media upload ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);@@@ -31,7 +31,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };if (json !== undefined) h['Content-Type'] = 'application/json';const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });-- if (res.status === 401 && !retried) { tokens.invalidate(); return call(method, path, { json, body, headers }, true); }++ if (res.status === 401 && !retried) { tokens.invalidate(); return await call(method, path, { json, body, headers }, true); }const data = await res.json().catch(() => null);if (!res.ok) {const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);@@@ -26,15 +26,19 @@ function probe(url, fetchImpl) {};}--function createCommunityReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch }) {++function createCommunityReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch, valkey = null }) {const checks = [{name: 'db', required: true,-- check: () => {-- const n = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;-- return n > 0 || 'database has no tables';++ // A real round trip that names the store (postgresql / pglite), and a migrated schema.++ 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()').get()).n;++ return n > 0 ? { ok: true, detail: r.detail } : 'database has no tables (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: () => {@@@ -54,11 +58,11 @@ function createCommunityReadiness({ db, auth, config, relay = null, release = nuservice: 'community',release,checks,-- details: (body) => {++ details: async (body) => {const out = { pastes_authority: config.pastesAuthority };if (relay && relay.enabled && body.checks.db.status === 'ok') {// The queue by status (failed = dead letters), and whether the Events worker and the inbound gateway run.-- const st = relay.status();++ const st = await relay.status();const brief = (x) => (x.enabled ? { enabled: true, state: x.state || (x.running ? 'running' : 'stopped'), last_error: x.last_error || null, ...(x.lag != null ? { lag: x.lag } : {}) } : { enabled: false, reason: x.reason });out.discord_relay = { enabled: true, deliveries: st.deliveries, creates_from: st.creates_from, events_worker: brief(st.events_worker), inbound: brief(st.inbound), inbound_failures: st.inbound_failures };}@@@ -110,45 +110,45 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n// ── collection + literal paths (before /:slug) ───────────router.get('/', anyCap, (req, res, next) => (req.query.needs_ai && req.viewer.kind !== 'service'? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })-- : next()), run((req) => service.list(req.viewer, req.query)));++ : next()), run(async (req) => await service.list(req.viewer, req.query)));router.post('/', guard('community.paste.create'), limits('community.paste.create', { minute: 30, hour: 600 }), anonWriteLimiter, body,-- run((req) => (req.file ? service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : service.createText(req.viewer, req.body || {})), 201));++ run(async (req) => (req.file ? await service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : await service.createText(req.viewer, req.body || {})), 201));-- router.get('/config', anyCap, run((req) => service.config(req.viewer)));++ router.get('/config', anyCap, run(async (req) => await service.config(req.viewer)));-- router.get('/admin/stats', staffOnly, run(() => service.stats()));-- router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));-- router.delete('/admin/forks', staffOnly, staffWrite, run((req) => service.deleteForks(req.viewer)));-- router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run((req) => service.bulk(req.body || {}, req.viewer)));++ router.get('/admin/stats', staffOnly, run(async () => await service.stats()));++ router.get('/admin/forks', staffOnly, run(async (req) => await service.forks(req.query)));++ router.delete('/admin/forks', staffOnly, staffWrite, run(async (req) => await service.deleteForks(req.viewer)));++ router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run(async (req) => await service.bulk(req.body || {}, req.viewer)));// An image paste stores its file in OpenVibe.Media.router.post('/screenshot', guard('community.paste.create'), limits('community.paste.screenshot', { minute: 20, hour: 300 }), anonWriteLimiter, withFile,-- run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));++ run(async (req) => await service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));-- router.get('/by-user/:username', anyCap, run((req) => service.byUser(req.viewer, req.params.username, req.query)));++ router.get('/by-user/:username', anyCap, run(async (req) => await service.byUser(req.viewer, req.params.username, req.query)));// ── one paste ────────────────────────────────────────────-- router.get('/:slug', anyCap, run((req) => service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));-- router.put('/:slug', guard('community.paste.write'), limits('community.paste.edit', { minute: 30, hour: 300 }), json, jsonErrors, run((req) => service.update(req.viewer, slug(req), req.body || {})));-- router.delete('/:slug', guard('community.paste.write'), limits('community.paste.delete', { minute: 60, hour: 600 }), run((req) => service.remove(req.viewer, slug(req))));++ router.get('/:slug', anyCap, run(async (req) => await service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));++ router.put('/:slug', guard('community.paste.write'), limits('community.paste.edit', { minute: 30, hour: 300 }), json, jsonErrors, run(async (req) => await service.update(req.viewer, slug(req), req.body || {})));++ router.delete('/:slug', guard('community.paste.write'), limits('community.paste.delete', { minute: 60, hour: 600 }), run(async (req) => await service.remove(req.viewer, slug(req))));-- router.post('/:slug/censor', staffOnly, limits('community.paste.censor', { minute: 20, hour: 200 }), withFile, run((req) => service.censor(req.viewer, slug(req), req.file)));++ router.post('/:slug/censor', staffOnly, limits('community.paste.censor', { minute: 20, hour: 200 }), withFile, run(async (req) => await service.censor(req.viewer, slug(req), req.file)));// Live's AI pass writes one paste's summary at a time, behind a model call.-- router.post('/:slug/ai', serviceModerator, limits('community.paste.ai', { minute: 60, hour: 1200 }), json, jsonErrors, run((req) => service.setAi(req.viewer, slug(req), req.body || {})));++ router.post('/:slug/ai', serviceModerator, limits('community.paste.ai', { minute: 60, hour: 1200 }), json, jsonErrors, run(async (req) => await service.setAi(req.viewer, slug(req), req.body || {})));router.get('/:slug/raw', (req, res) => res.redirect(302, `/p/${encodeURIComponent(slug(req))}/raw`));-- router.get('/:slug/versions', anyCap, run((req) => service.versions(req.viewer, slug(req))));++ router.get('/:slug/versions', anyCap, run(async (req) => await service.versions(req.viewer, slug(req))));-- router.post('/:slug/fork', guard('community.paste.write'), limits('community.paste.fork', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors, run((req) => service.fork(req.viewer, slug(req)), 201));-- router.post('/:slug/like', guard('community.paste.write'), react, run((req) => service.like(req.viewer, slug(req))));-- router.post('/:slug/copy', guard('community.paste.write'), react, run((req) => service.copy(req.viewer, slug(req))));++ router.post('/:slug/fork', guard('community.paste.write'), limits('community.paste.fork', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors, run(async (req) => await service.fork(req.viewer, slug(req)), 201));++ router.post('/:slug/like', guard('community.paste.write'), react, run(async (req) => await service.like(req.viewer, slug(req))));++ router.post('/:slug/copy', guard('community.paste.write'), react, run(async (req) => await service.copy(req.viewer, slug(req))));-- router.get('/:slug/comments', anyCap, run((req) => service.comments(req.viewer, slug(req), req.query)));++ router.get('/:slug/comments', anyCap, run(async (req) => await service.comments(req.viewer, slug(req), req.query)));router.post('/:slug/comments', guard('community.paste.write'), limits('community.paste.comment', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors,-- run((req) => service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));++ run(async (req) => await service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));// Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.-- router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run((req) => service.deleteComment(req.viewer, slug(req), req.params.commentId)));++ router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run(async (req) => await service.deleteComment(req.viewer, slug(req), req.params.commentId)));// Avatars belong to the Network account; Live's proxy set a Live-local avatar here.router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));ddeleted file mode 100644@@@ -1,326 +0,0 @@--'use strict';----/**-- * Paste importer — OpenVibe.Media's paste export bundle → Community's store.-- *-- * Bundle (produced by Media, format openvibe.media.pastes-export v1):-- * { format, version, app, generated_at, since_id, max_id, sha256, counts: {pastes, comments, likes},-- * pastes: [...], comments: [...], likes: [...] }-- * sha256 is over JSON.stringify({ pastes, comments, likes }).-- *-- * Rules:-- * - The bundle is verified (format, version, counts, sha256) before anything is touched.-- * - Idempotent: rows are upserted by legacy_media_id, so a re-run changes nothing and a newer-- * bundle adds and updates. A paste deleted in Community stays deleted.-- * - Slugs, timestamps, counters, visibility, NSFW and AI fields are kept. Counters never go-- * down (Community may have counted views since the last run).-- * - Owners: bundle user_ids are Live user ids, except rows created before the id-space fix-- * (cutoff) which may hold a Network user id instead. Every id is resolved through the Network-- * (system=live, and system=network for pre-cutoff rows). Pre-cutoff with two different-- * answers → imported ownerless + hold 'ambiguous_owner'; no answer → ownerless + hold-- * 'owner_unmapped'. A like that can't be mapped is held and not imported.-- * - AI moment pastes (Live's AI jobs) get origin 'ai' and no owner: AI output is never-- * attributed to a person. Their stream goes into stream_ref.-- * - Every mapping lands in legacy_id_map; every run in migration_runs with a reconciliation report.-- * - dryRun does all of it inside a transaction and rolls back.-- */--const crypto = require('crypto');--const { ids } = require('openvibe-contracts');--const store = require('./store');----const FORMAT = 'openvibe.media.pastes-export';--const DEFAULT_CUTOFF = '2026-08-20T03:00:26Z';--// ac0d6a4 "Initial OpenVibe release" (2026-08-17 14:10:29 -07:00): earlier rows came from HoboStreamer's database.--const DEFAULT_AMBIGUOUS_FROM = '2026-08-17T21:10:29Z';--// Media rebuilt screenshot metadata itself, so the ai_moment flag Live sent was dropped for image--// pastes; the frame file names Live's AI jobs upload are the remaining marker.--const AI_FRAME_NAME = /^(ai-moment-vod\d+-\d+|live-\d+-\d+)\.jpe?g$/i;----class ImportError extends Error {}--class Rollback extends Error {}----function toMs(ts) {-- if (!ts) return NaN;-- const s = String(ts);-- return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);--}----function objectOf(v) {-- if (v && typeof v === 'object' && !Array.isArray(v)) return v;-- if (typeof v === 'string') { try { const o = JSON.parse(v); return o && typeof o === 'object' && !Array.isArray(o) ? o : null; } catch { return null; } }-- return null;--}----function isAiPaste(p) {-- const meta = objectOf(p.metadata);-- if (meta && meta.ai_moment === true) return true;-- return p.type === 'screenshot' && !!meta && typeof meta.original_name === 'string' && AI_FRAME_NAME.test(meta.original_name);--}----/** Refuse a bundle that isn't exactly what it says it is. */--function verifyBundle(bundle) {-- if (!bundle || typeof bundle !== 'object') throw new ImportError('bundle is not a JSON object');-- if (bundle.format !== FORMAT) throw new ImportError(`unexpected format ${JSON.stringify(bundle.format)} (want ${FORMAT})`);-- if (bundle.version !== 1) throw new ImportError(`unsupported version ${JSON.stringify(bundle.version)} (want 1)`);-- for (const k of ['pastes', 'comments', 'likes']) {-- if (!Array.isArray(bundle[k])) throw new ImportError(`bundle.${k} is not an array`);-- const want = bundle.counts && bundle.counts[k];-- if (want !== bundle[k].length) throw new ImportError(`count mismatch for ${k}: counts says ${want}, bundle has ${bundle[k].length}`);-- }-- const sha = crypto.createHash('sha256').update(JSON.stringify({ pastes: bundle.pastes, comments: bundle.comments, likes: bundle.likes })).digest('hex');-- if (sha !== String(bundle.sha256 || '').toLowerCase()) throw new ImportError(`sha256 mismatch: bundle says ${bundle.sha256}, content is ${sha}`);--}----/**-- * Import a verified bundle.-- * opts.resolveLegacy(system, ids) → Map<String(id), projection|null> (Network resolve-batch)-- * opts.cutoff ISO time of the id-space fix-- * opts.dryRun roll everything back at the end-- * opts.source label for migration_runs-- * → report-- */--async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF, ambiguousFrom = DEFAULT_AMBIGUOUS_FROM, dryRun = false, source = 'media' } = {}) {-- verifyBundle(bundle);-- const cutoffMs = toMs(cutoff);-- if (!Number.isFinite(cutoffMs)) throw new ImportError(`bad cutoff ${cutoff}`);-- const fromMs = ambiguousFrom ? toMs(ambiguousFrom) : -Infinity;-- if (!Number.isFinite(fromMs) && fromMs !== -Infinity) throw new ImportError(`bad ambiguous-from ${ambiguousFrom}`);-- // Only rows written between the OpenVibe launch and the id-space fix can hold a Network id: rows from-- // before the launch were migrated from HoboStreamer's own database, whose user ids Live kept.-- const inWindow = (createdAt) => { const t = toMs(createdAt); return t >= fromMs && !(t >= cutoffMs); };-- const startedAt = new Date().toISOString();---- // ── 1. Resolve every legacy user id before the transaction (network calls) ──-- const liveIds = new Set();-- const preCutoffIds = new Set();-- const note = (userId, createdAt) => {-- if (userId == null) return;-- liveIds.add(String(userId));-- if (inWindow(createdAt)) preCutoffIds.add(String(userId));-- };-- for (const p of bundle.pastes) if (!isAiPaste(p)) note(p.user_id, p.created_at);-- for (const c of bundle.comments) note(c.user_id, c.created_at);-- for (const l of bundle.likes) note(l.user_id, l.created_at);-- const subjectOf = (proj) => (proj && proj.subject && proj.subject.type === 'user' ? proj.subject.id : null);-- const liveMap = liveIds.size ? await resolveLegacy('live', [...liveIds]) : new Map();-- const netMap = preCutoffIds.size ? await resolveLegacy('network', [...preCutoffIds]) : new Map();---- function owner(userId, createdAt) {-- if (userId == null) return { subject: null, hold: null };-- const key = String(userId);-- const live = subjectOf(liveMap.get(key));-- const pre = inWindow(createdAt);-- if (!pre) return live ? { subject: live, hold: null } : { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'post-cutoff' } };-- const net = subjectOf(netMap.get(key));-- if (live && net && live !== net) return { subject: null, hold: 'ambiguous_owner', detail: { user_id: userId, created_at: createdAt, live_subject: live, network_subject: net } };-- if (live || net) return { subject: live || net, hold: null };-- return { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'pre-cutoff' } };-- }---- const blank = () => ({ bundle: 0, inserted: 0, updated: 0, unchanged: 0, held: 0, skipped: 0 });-- const report = {-- source, app: bundle.app || null, generated_at: bundle.generated_at || null, since_id: bundle.since_id ?? null, max_id: bundle.max_id ?? null,-- cutoff, ambiguous_from: ambiguousFrom || null, dry_run: !!dryRun,-- pastes: { ...blank(), bundle: bundle.pastes.length, ai: 0, forks_remapped: 0, forks_unresolved: 0 },-- comments: { ...blank(), bundle: bundle.comments.length },-- likes: { ...blank(), bundle: bundle.likes.length },-- holds: {},-- users: { live_ids: liveIds.size, live_mapped: [...liveMap.values()].filter(subjectOf).length, network_checked: preCutoffIds.size, network_mapped: [...netMap.values()].filter(subjectOf).length },-- };-- const countHold = (reason) => { report.holds[reason] = (report.holds[reason] || 0) + 1; };---- const hold = db.prepare('INSERT OR IGNORE INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)');-- const clearOwnerHolds = db.prepare("DELETE FROM import_hold WHERE source_type = ? AND source_id = ? AND reason IN ('ambiguous_owner', 'owner_unmapped')");-- const byLegacy = db.prepare('SELECT * FROM pastes WHERE legacy_media_id = ?');-- const slugOwner = db.prepare('SELECT id, legacy_media_id FROM pastes WHERE slug = ?');-- const commentByLegacy = db.prepare('SELECT * FROM paste_comments WHERE legacy_media_id = ?');---- const run = db.transaction(() => {-- // Users seen: record the mappings the Network gave us.-- for (const [id, proj] of liveMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'live', 'user', id, 'subject', s); }-- for (const [id, proj] of netMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'network', 'user', id, 'subject', s); }---- // ── 2. Pastes ──-- const PASTE_TEXT_FIELDS = ['type', 'title', 'content', 'language', 'visibility', 'screenshot_url', 'media_ref', 'stream_ref', 'metadata',-- 'burn_after_read', 'pinned', 'is_nsfw', 'ai_summary', 'ai_tags', 'ai_analyzed_at', 'origin', 'updated_at'];-- const COUNTERS = ['views', 'unique_views', 'copies', 'likes'];-- for (const p of bundle.pastes) {-- const ai = isAiPaste(p);-- if (ai) report.pastes.ai++;-- const who = ai ? { subject: null, hold: null } : owner(p.user_id, p.created_at);-- const meta = objectOf(p.metadata);-- const streamId = p.stream_id != null ? p.stream_id : (meta && meta.stream_id != null ? meta.stream_id : null);-- const row = {-- slug: String(p.slug),-- origin: ai ? 'ai' : 'user',-- type: p.type === 'screenshot' ? 'screenshot' : 'paste',-- title: store.sanitizeTitle(p.title),-- content: p.content == null ? null : String(p.content),-- language: p.language || 'text',-- visibility: ['public', 'unlisted', 'private'].includes(p.visibility) ? p.visibility : 'public',-- screenshot_url: p.screenshot_url || null,-- media_ref: p.type === 'screenshot' ? ids.legacyMediaId(bundle.app || 'live', 'paste', p.id) : null,-- stream_ref: streamId != null && streamId !== '' ? JSON.stringify({ service: 'live', type: 'stream', id: String(streamId) }) : null,-- metadata: meta ? JSON.stringify(meta) : null,-- burn_after_read: p.burn_after_read ? 1 : 0,-- pinned: p.pinned ? 1 : 0,-- is_nsfw: p.is_nsfw ? 1 : 0,-- ai_summary: p.ai_summary || null,-- ai_tags: p.ai_tags == null ? null : (typeof p.ai_tags === 'string' ? p.ai_tags : JSON.stringify(p.ai_tags)),-- ai_analyzed_at: p.ai_analyzed_at || null,-- views: Number(p.views) || 0, unique_views: Number(p.unique_views) || 0, copies: Number(p.copies) || 0, likes: Number(p.likes) || 0,-- created_at: p.created_at, updated_at: p.updated_at || p.created_at,-- };-- const existing = byLegacy.get(p.id);-- let communityId;-- if (!existing) {-- const clash = slugOwner.get(row.slug);-- if (clash) {-- hold.run('paste', String(p.id), 'slug_conflict', JSON.stringify({ slug: row.slug, community_id: clash.id }));-- countHold('slug_conflict'); report.pastes.skipped++;-- continue;-- }-- const cols = Object.keys(row).concat(['owner_subject', 'legacy_media_id', 'legacy_user_id']);-- const vals = Object.values(row).concat([who.subject, p.id, p.user_id == null ? null : Number(p.user_id)]);-- const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...vals);-- communityId = info.lastInsertRowid;-- report.pastes.inserted++;-- } else {-- communityId = existing.id;-- if (existing.deleted_at) { report.pastes.unchanged++; store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId); continue; }-- const patch = {};-- // Edited here since the last import? Then Community's text wins over the bundle's.-- const localNewer = existing.revision > 1 || toMs(existing.updated_at) > toMs(row.updated_at);-- if (!localNewer) for (const k of PASTE_TEXT_FIELDS) if (row[k] !== existing[k]) patch[k] = row[k];-- for (const k of COUNTERS) if (row[k] > existing[k]) patch[k] = row[k];-- if (who.subject && !existing.owner_subject) patch.owner_subject = who.subject;-- if (row.origin === 'ai' && existing.owner_subject && existing.origin !== 'ai') patch.owner_subject = null;-- const legacyUser = p.user_id == null ? null : Number(p.user_id);-- if (legacyUser !== existing.legacy_user_id) patch.legacy_user_id = legacyUser;-- const keys = Object.keys(patch);-- if (keys.length) {-- db.prepare(`UPDATE pastes SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), communityId);-- report.pastes.updated++;-- } else {-- report.pastes.unchanged++;-- }-- }-- store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId);-- if (who.hold) {-- hold.run('paste', String(p.id), who.hold, JSON.stringify(who.detail));-- countHold(who.hold); report.pastes.held++;-- } else {-- clearOwnerHolds.run('paste', String(p.id));-- }-- }-- // Forks point at Community ids (second pass: the original may come later in the bundle).-- for (const p of bundle.pastes) {-- if (p.forked_from == null) continue;-- const target = store.mapGet(db, 'media', 'paste', p.forked_from);-- const self = store.mapGet(db, 'media', 'paste', p.id);-- if (!self) continue;-- if (!target) { report.pastes.forks_unresolved++; continue; }-- const changed = db.prepare('UPDATE pastes SET forked_from = ? WHERE id = ? AND forked_from IS NOT ?').run(Number(target.target_id), Number(self.target_id), Number(target.target_id)).changes;-- if (changed) report.pastes.forks_remapped++;-- }---- // ── 3. Comments ──-- const comments = bundle.comments.slice().sort((a, b) => a.id - b.id);-- for (const c of comments) {-- const paste = store.mapGet(db, 'media', 'paste', c.paste_id);-- if (!paste) {-- hold.run('comment', String(c.id), 'paste_missing', JSON.stringify({ paste_id: c.paste_id }));-- countHold('paste_missing'); report.comments.skipped++;-- continue;-- }-- const who = owner(c.user_id, c.created_at);-- const row = {-- paste_id: Number(paste.target_id), anon_name: c.anon_name || null, message: String(c.message == null ? '' : c.message),-- is_deleted: c.is_deleted ? 1 : 0, created_at: c.created_at, updated_at: c.updated_at || c.created_at,-- };-- const existing = commentByLegacy.get(c.id);-- let cid;-- if (!existing) {-- const info = db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, message, is_deleted, created_at, updated_at, legacy_media_id)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)-- .run(row.paste_id, who.subject, row.anon_name, row.message, row.is_deleted, row.created_at, row.updated_at, c.id);-- cid = info.lastInsertRowid;-- report.comments.inserted++;-- } else {-- cid = existing.id;-- const patch = {};-- for (const k of ['message', 'anon_name', 'updated_at']) if (row[k] !== existing[k]) patch[k] = row[k];-- // Deletion is one-way: a comment removed on either side stays removed.-- if (row.is_deleted && !existing.is_deleted) patch.is_deleted = 1;-- if (who.subject && !existing.author_subject) patch.author_subject = who.subject;-- const keys = Object.keys(patch);-- if (keys.length) {-- db.prepare(`UPDATE paste_comments SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), cid);-- report.comments.updated++;-- } else {-- report.comments.unchanged++;-- }-- }-- store.mapSet(db, 'media', 'comment', c.id, 'comment', cid);-- if (who.hold) {-- hold.run('comment', String(c.id), who.hold, JSON.stringify(who.detail));-- countHold(who.hold); report.comments.held++;-- } else {-- clearOwnerHolds.run('comment', String(c.id));-- }-- }-- for (const c of comments) {-- if (c.parent_id == null) continue;-- const self = store.mapGet(db, 'media', 'comment', c.id);-- const parent = store.mapGet(db, 'media', 'comment', c.parent_id);-- if (self && parent) db.prepare('UPDATE paste_comments SET parent_id = ? WHERE id = ? AND parent_id IS NOT ?').run(Number(parent.target_id), Number(self.target_id), Number(parent.target_id));-- }---- // ── 4. Likes (only ever imported for a mapped person) ──-- for (const l of bundle.likes) {-- const key = `${l.paste_id}:${l.user_id}`;-- const paste = store.mapGet(db, 'media', 'paste', l.paste_id);-- if (!paste) {-- hold.run('like', key, 'paste_missing', JSON.stringify({ paste_id: l.paste_id, user_id: l.user_id }));-- countHold('paste_missing'); report.likes.skipped++;-- continue;-- }-- const who = l.user_id == null ? { subject: null, hold: 'owner_unmapped', detail: { user_id: null } } : owner(l.user_id, l.created_at);-- if (!who.subject) {-- hold.run('like', key, who.hold, JSON.stringify(who.detail));-- countHold(who.hold); report.likes.held++;-- continue;-- }-- clearOwnerHolds.run('like', key);-- const info = db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id, created_at) VALUES (?, ?, ?)').run(Number(paste.target_id), who.subject, l.created_at || null);-- if (info.changes) report.likes.inserted++; else report.likes.unchanged++;-- }---- report.finished_at = new Date().toISOString();-- db.prepare('INSERT INTO migration_runs (source, started_at, finished_at, report) VALUES (?, ?, ?, ?)')-- .run(source, startedAt, report.finished_at, JSON.stringify(report));-- if (dryRun) throw new Rollback();-- });---- try { run(); } catch (err) { if (!(err instanceof Rollback)) throw err; }-- return report;--}----/** Plain-text reconciliation table. */--function formatReport(r) {-- const line = (name, t) => ` ${name.padEnd(9)} ${String(t.bundle).padStart(7)} ${String(t.inserted).padStart(9)} ${String(t.updated).padStart(8)} ${String(t.unchanged).padStart(10)} ${String(t.held).padStart(6)} ${String(t.skipped).padStart(8)}`;-- const out = [-- `Paste import from ${r.source}${r.dry_run ? ' (DRY RUN — rolled back)' : ''}`,-- ` bundle app=${r.app} ids ${r.since_id ?? '?'}..${r.max_id ?? '?'} generated ${r.generated_at || '?'}; ambiguous id window ${r.ambiguous_from || 'start'} .. ${r.cutoff}`,-- '',-- ` ${'table'.padEnd(9)} ${'bundle'.padStart(7)} ${'inserted'.padStart(9)} ${'updated'.padStart(8)} ${'unchanged'.padStart(10)} ${'held'.padStart(6)} ${'skipped'.padStart(8)}`,-- line('pastes', r.pastes), line('comments', r.comments), line('likes', r.likes),-- '',-- ` AI pastes (ownerless): ${r.pastes.ai}; forks remapped: ${r.pastes.forks_remapped}, unresolved: ${r.pastes.forks_unresolved}`,-- ` users: ${r.users.live_ids} live ids (${r.users.live_mapped} mapped), ${r.users.network_checked} in-window ids checked as network ids (${r.users.network_mapped} mapped)`,-- ` holds: ${Object.keys(r.holds).length ? Object.entries(r.holds).map(([k, v]) => `${k}=${v}`).join(', ') : 'none'}`,-- ];-- return out.join('\n');--}----module.exports = { DEFAULT_AMBIGUOUS_FROM, importBundle, verifyBundle, formatReport, isAiPaste, ImportError, FORMAT, DEFAULT_CUTOFF };@@@ -68,7 +68,7 @@ function sinceParam(value) {function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {const L = { ...DEFAULT_LIMITS, ...limits };// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.-- const tell = (fn) => { if (!pulse) return; try { fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };++ const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };const visitorSecret = process.env.VIEW_HASH_SECRET|| crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');@@@ -86,8 +86,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim* A paste the viewer may see, or 404: private ones look exactly like missing ones to everyone* but their owner and staff — for reads, and for edits/deletes too (no 403 that confirms a slug).*/-- function visible(v, slug) {-- const p = store.getBySlug(db, String(slug));++ async function visible(v, slug) {++ const p = await store.getBySlug(db, String(slug));if (!p || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Paste not found');return p;}@@@ -153,8 +153,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim}async function projectionsFor(subjects) {-- if (!network) return store.getProjections(db, subjects);-- try { return await network.projections(subjects); } catch { return store.getProjections(db, subjects); }++ if (!network) return await store.getProjections(db, subjects);++ try { return await network.projections(subjects); } catch { return await store.getProjections(db, subjects); }}async function shapeMany(rows, v, { preview = false } = {}) {@@@ -179,16 +179,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim}// ── rate limits (acting persons) ─────────────────────────-- function pasteRateCheck(v) {++ async function pasteRateCheck(v) {if (!limited(v)) return;if (L.cooldownSeconds > 0) {-- const elapsed = (Date.now() - store.lastPasteTime(db, v.subject)) / 1000;++ const elapsed = (Date.now() - await store.lastPasteTime(db, v.subject)) / 1000;if (elapsed < L.cooldownSeconds) {const wait = Math.ceil(L.cooldownSeconds - elapsed);fail(429, `Please wait ${wait}s before creating another paste`, { cooldown: wait });}}-- if (L.maxPerUserPerDay > 0 && store.countOwnerSince(db, v.subject, '-1 day') >= L.maxPerUserPerDay) {++ if (L.maxPerUserPerDay > 0 && await store.countOwnerSince(db, v.subject, '-1 day') >= L.maxPerUserPerDay) {fail(429, `Daily paste limit reached (${L.maxPerUserPerDay}/day)`);}}@@@ -220,25 +220,25 @@ function createPasteService({ db, network = null, media = null, config = {}, limb.n += 1;return b.n > L.viewEventsPerMinPerIp;}-- const sweep = setInterval(() => {++ const sweep = setInterval(async () => {const now = Date.now();for (const [k, b] of ipBudget) if (now - b.start > 120_000) ipBudget.delete(k);for (const [k, l] of recentComments) if (!l.length || now - l[0].at > 120_000) recentComments.delete(k);-- try { store.pruneVisits(db, 30); } catch { /* db closed in tests */ }++ try { await store.pruneVisits(db, 30); } catch { /* db closed in tests */ }}, 10 * 60_000);if (sweep.unref) sweep.unref();-- function countView(v, p, { ip, userAgent } = {}) {++ async function countView(v, p, { ip, userAgent } = {}) {if (BOT_UA.test(String(userAgent || ''))) return;if (overBudget(ip || 'unknown')) return;const visitor = v && v.subject ? `u:${v.subject}` : `ip:${crypto.createHmac('sha256', visitorSecret).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;-- const r = store.recordVisit(db, p.id, visitor, L.viewCooldownSec);++ const r = await store.recordVisit(db, p.id, visitor, L.viewCooldownSec);p.views = r.views; p.unique_views = r.unique_views;}/** Burn-after-read: the paste is gone the moment this read makes it spent. */-- function burn(p) {-- store.softDelete(db, p.id);++ async function burn(p) {++ await store.softDelete(db, p.id);fail(410, 'This paste has been burned after reading.');}@@@ -277,16 +277,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim* unlisted or private paste always gets a secret slug: its slug is its only protection, and a* service forwarding a person's request (Live's /api/pastes) passes their body through as-is.*/-- function slugFor(v, body, visibility) {-- if (!isService(v) || body.slug == null || body.slug === '' || visibility !== 'public') return store.generateSlug(db, { secret: visibility !== 'public' });++ async function slugFor(v, body, visibility) {++ if (!isService(v) || body.slug == null || body.slug === '' || visibility !== 'public') return await store.generateSlug(db, { secret: visibility !== 'public' });const slug = String(body.slug);if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');-- if (db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');++ if (await db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');return slug;}-- function created(row, extra = {}) {-- tell((p) => p.pasteCreated(row));++ async function created(row, extra = {}) {++ await tell((p) => p.pasteCreated(row));return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };}@@@ -308,79 +308,79 @@ function createPasteService({ db, network = null, media = null, config = {}, lim// ?username= lists one person's pastes (Live resolved it to its own id; we go through// the projection cache). Their own unlisted/private ones only for them (or staff).if (q.username && q.username !== 'all') {-- const subject = store.subjectsByUsername(db, q.username)[0];++ const subject = (await store.subjectsByUsername(db, q.username))[0];if (!subject) return { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false };opts.ownerSubject = subject;if (truthy(q.include_unlisted) && (v.subject === subject || isStaff(v))) opts.includeHidden = true;}-- const { rows, total } = store.listPastes(db, opts);++ const { rows, total } = await store.listPastes(db, opts);return { pastes: await shapeMany(rows, v, { preview: true }), total, limit, offset };},/** GET /api/pastes/by-user/:username */async byUser(v, username, q = {}) {-- const subject = store.subjectsByUsername(db, username)[0];++ const subject = (await store.subjectsByUsername(db, username))[0];if (!subject) fail(404, 'User not found');const limit = intIn(q.limit, 30, 1, 100);const includeHidden = v.subject === subject;-- const { rows, total } = store.listPastes(db, { ownerSubject: subject, includeHidden, limit, offset: 0, sort: q.sort === 'oldest' ? 'oldest' : 'newest' });-- const proj = store.getProjections(db, [subject]).get(subject);++ const { rows, total } = await store.listPastes(db, { ownerSubject: subject, includeHidden, limit, offset: 0, sort: q.sort === 'oldest' ? 'oldest' : 'newest' });++ const proj = (await store.getProjections(db, [subject])).get(subject);return { pastes: await shapeMany(rows, v, { preview: true }), total, username: (proj && proj.username) || String(username) };},/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */async get(v, slug, ctx = {}) {-- const p = visible(v, slug);++ const p = await visible(v, slug);// ?no_view=1 skips view counting, never the burn: any read of a burn paste is the read.if ((!ctx.noView || p.burn_after_read) && !isOwner(v, p)) {if (p.burn_after_read) {-- p.views = store.bumpViews(db, p.id);-- if (p.views > 1) burn(p);++ p.views = await store.bumpViews(db, p.id);++ if (p.views > 1) await burn(p);} else {-- countView(v, p, ctx);++ await countView(v, p, ctx);}}-- const liked = v.subject ? store.hasLiked(db, p.id, v.subject) : false;++ const liked = v.subject ? await store.hasLiked(db, p.id, v.subject) : false;return { paste: { ...(await shapeOne(p, v)), liked } };},/** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */-- raw(v, slug, ctx = {}) {-- const p = store.getBySlug(db, String(slug));++ async raw(v, slug, ctx = {}) {++ const p = await store.getBySlug(db, String(slug));if (p && p.type === 'screenshot' && (p.visibility !== 'private' || canSeeHidden(v, p))) return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };if (!p || p.type !== 'paste' || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');if (!isOwner(v, p)) {if (p.burn_after_read) {-- if (p.views > 0) burn(p);-- store.bumpViews(db, p.id);++ if (p.views > 0) await burn(p);++ await store.bumpViews(db, p.id);} else {-- countView(v, p, ctx);++ await countView(v, p, ctx);}}return { content: String(p.content || '') };},/** GET /p/:slug/screenshot target (the stored Media URL). For a burn-after-read screenshot it is a read (raw's rule). */-- screenshotUrl(v, slug) {-- const p = store.getBySlug(db, String(slug));++ async screenshotUrl(v, slug) {++ const p = await store.getBySlug(db, String(slug));if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');if (p.burn_after_read && !isOwner(v, p)) {-- if (p.views > 0) burn(p);-- store.bumpViews(db, p.id);++ if (p.views > 0) await burn(p);++ await store.bumpViews(db, p.id);}return p.screenshot_url;},/** POST /api/pastes (text) */async createText(v, body = {}) {-- pasteRateCheck(v);++ await pasteRateCheck(v);const content = body.content;if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);const metadata = serviceMetadata(v, body);const visibility = visibilityOf(body.visibility, !!v.subject);-- const row = store.insertPaste(db, {-- slug: slugFor(v, body, visibility),++ const row = await store.insertPaste(db, {++ slug: await slugFor(v, body, visibility),owner_subject: v.subject || null,origin: v.origin === 'ai' ? 'ai' : 'user',type: 'paste',@@@ -394,18 +394,18 @@ function createPasteService({ db, network = null, media = null, config = {}, limis_nsfw: truthy(body.is_nsfw) ? 1 : 0,...aiFields(v, body),});-- return created(row, { paste: await shapeOne(row, v) });++ return await created(row, { paste: await shapeOne(row, v) });},/** POST /api/pastes/screenshot (and multipart POST /api/pastes) */async createScreenshot(v, body = {}, file, ctx = {}) {-- pasteRateCheck(v);++ await pasteRateCheck(v);if (!file || !file.buffer) fail(400, 'No screenshot uploaded');if (!IMAGE_MIME.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');if (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);if (!media) fail(503, 'Media service unavailable');const visibility = visibilityOf(body.visibility, !!v.subject);-- const slug = slugFor(v, body, visibility);++ const slug = await slugFor(v, body, visibility);const extra = serviceMetadata(v, body);const bytes = stripImageMetadata(file.buffer, file.mimetype);let stored;@@@ -423,7 +423,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limsize_bytes: bytes.length,mime_type: file.mimetype,};-- const row = store.insertPaste(db, {++ const row = await store.insertPaste(db, {slug,owner_subject: v.subject || null,origin: v.origin === 'ai' ? 'ai' : 'user',@@@ -440,13 +440,13 @@ function createPasteService({ db, network = null, media = null, config = {}, limis_nsfw: truthy(body.is_nsfw) ? 1 : 0,...aiFields(v, body),});-- return created(row, { paste: await shapeOne(row, v) });++ return await created(row, { paste: await shapeOne(row, v) });},/** PUT /api/pastes/:slug — owner (or staff) only. */async update(v, slug, body = {}) {needIdentity(v);-- const p = visible(v, slug);++ const p = await visible(v, slug);if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');const patch = {};if (body.title !== undefined) patch.title = store.sanitizeTitle(body.title);@@@ -460,49 +460,49 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;if (!Object.keys(patch).length) fail(400, 'Nothing to update');-- const row = db.transaction(() => {-- const r = store.updatePaste(db, p.id, patch, v.subject || null);++ const row = await db.tx(async () => {++ const r = await store.updatePaste(db, p.id, patch, v.subject || null);// Staff editing someone else's paste goes to the moderation audit log (ADR-022).-- if (!isOwner(v, p) && isStaff(v)) events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });++ if (!isOwner(v, p) && isStaff(v)) await events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });return r;-- })();-- tell((pl) => pl.pasteChanged(row));++ });++ await tell((pl) => pl.pasteChanged(row));return { paste: await shapeOne(row, v) };},/** DELETE /api/pastes/:slug — owner (or staff) only. */-- remove(v, slug) {++ async remove(v, slug) {needIdentity(v);-- const p = visible(v, slug);++ const p = await visible(v, slug);if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');-- db.transaction(() => {-- store.softDelete(db, p.id);-- if (!isOwner(v, p)) events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });-- })();-- tell((pl) => pl.pasteGone(p.slug));++ await db.tx(async () => {++ await store.softDelete(db, p.id);++ if (!isOwner(v, p)) await events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });++ });++ await tell((pl) => pl.pasteGone(p.slug));return { success: true };},/** GET /api/pastes/:slug/versions — edit history, owner (or staff) only. */-- versions(v, slug) {++ async versions(v, slug) {needIdentity(v);-- const p = visible(v, slug);++ const p = await visible(v, slug);if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');-- const list = store.listVersions(db, p.id);++ const list = await store.listVersions(db, p.id);return { revision: p.revision, versions: list.length ? list : [{ revision: p.revision, title: p.title, content: p.content, language: p.language, edited_by: p.owner_subject, created_at: p.updated_at }] };},/** POST /api/pastes/:slug/fork */async fork(v, slug) {-- const original = visible(v, slug);++ const original = await visible(v, slug);if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');// A lasting copy would outlive the burn; only the owner (or staff) may fork one.if (original.burn_after_read && !canSeeHidden(v, original)) fail(403, 'Burn-after-read pastes cannot be forked');-- pasteRateCheck(v);++ await pasteRateCheck(v);// A fork never widens who can find the content: unlisted stays unlisted (and gets a secret slug).const visibility = visibilityOf(original.visibility, !!v.subject);-- const row = store.insertPaste(db, {-- slug: store.generateSlug(db, { secret: visibility !== 'public' }),++ const row = await store.insertPaste(db, {++ slug: await store.generateSlug(db, { secret: visibility !== 'public' }),owner_subject: v.subject || null,origin: v.origin === 'ai' ? 'ai' : 'user',type: 'paste',@@@ -512,37 +512,37 @@ function createPasteService({ db, network = null, media = null, config = {}, limvisibility,forked_from: original.id,});-- return created(row, { paste: await shapeOne(row, v) });++ return await created(row, { paste: await shapeOne(row, v) });},/** POST /api/pastes/:slug/like — toggles; needs a person. */-- like(v, slug) {++ async like(v, slug) {if (!v.subject) fail(401, 'Authentication required');-- const p = visible(v, slug);-- return store.toggleLike(db, p.id, v.subject);++ const p = await visible(v, slug);++ return await store.toggleLike(db, p.id, v.subject);},/** POST /api/pastes/:slug/copy */-- copy(v, slug) {-- const p = visible(v, slug);-- return { copies: store.incrementCopies(db, p.id) };++ async copy(v, slug) {++ const p = await visible(v, slug);++ return { copies: await store.incrementCopies(db, p.id) };},/** GET /api/pastes/:slug/comments */async comments(v, slug, q = {}) {-- const p = visible(v, slug);++ const p = await visible(v, slug);const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);-- const list = store.listComments(db, p.id, limit, offset);++ const list = await store.listComments(db, p.id, limit, offset);const subjects = [];for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }const projections = await projectionsFor(subjects);-- return { comments: list.map((c) => shapeComment(c, projections)), total: store.countComments(db, p.id) };++ return { comments: list.map((c) => shapeComment(c, projections)), total: await store.countComments(db, p.id) };},/** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */async addComment(v, slug, body = {}, ctx = {}) {-- const p = visible(v, slug);++ const p = await visible(v, slug);const author = v.subject || null;if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');const message = String(body.message || '').trim();@@@ -558,85 +558,85 @@ function createPasteService({ db, network = null, media = null, config = {}, limconst parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;let parent = null;if (parentId) {-- parent = store.getComment(db, parentId);++ parent = await store.getComment(db, parentId);if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');}// Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.-- if (author && blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });-- if (author && parent && blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });-- const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });++ if (author && await blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });++ if (author && parent && await blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });++ const c = await store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });if (limited(v)) commentRecorded(rateKey, message);return { comment: shapeComment(c, await projectionsFor([author])) };},/** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */-- deleteComment(v, slug, commentId) {++ async deleteComment(v, slug, commentId) {needIdentity(v);-- const p = visible(v, slug);-- const c = store.getComment(db, parseInt(commentId, 10));++ const p = await visible(v, slug);++ const c = await store.getComment(db, parseInt(commentId, 10));if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');-- db.transaction(() => {-- store.softDeleteComment(db, c.id);-- if (!isAuthor && !isOwner(v, p)) events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });-- })();++ await db.tx(async () => {++ await store.softDeleteComment(db, c.id);++ if (!isAuthor && !isOwner(v, p)) await events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });++ });return { message: 'Comment deleted' };},/** GET /api/pastes/config */-- config(v) {++ async config(v) {return {maxSizeKb: L.maxSizeKb,screenshotMaxSizeMb: L.screenshotMaxSizeMb,cooldownSeconds: L.cooldownSeconds,maxPerUserPerDay: L.maxPerUserPerDay,-- todayCount: v.subject ? store.countOwnerSince(db, v.subject, '-1 day') : 0,++ todayCount: v.subject ? await store.countOwnerSince(db, v.subject, '-1 day') : 0,};},// ── staff (api.js has already checked the viewer is staff) ──-- stats() { return { stats: store.stats(db) }; },++ async stats() { return { stats: await store.stats(db) }; },-- forks(q = {}) {++ async forks(q = {}) {const limit = intIn(q.limit, 100, 1, 500);const offset = Math.max(parseInt(q.offset, 10) || 0, 0);-- const { forks, total } = store.listForks(db, limit, offset);++ const { forks, total } = await store.listForks(db, limit, offset);return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };},-- deleteForks(v) {-- return db.transaction(() => {-- const deleted = store.deleteAllForks(db);-- events.moderationAction(v, 'forks.deleted', { type: 'pastes', id: 'forks' }, { details: { deleted } });++ async deleteForks(v) {++ return await db.tx(async () => {++ const deleted = await store.deleteAllForks(db);++ await events.moderationAction(v, 'forks.deleted', { type: 'pastes', id: 'forks' }, { details: { deleted } });return { success: true, deleted };-- })();++ });},-- bulk(body = {}, v = null) {++ async bulk(body = {}, v = null) {const { slugs, action } = body;if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');let done = 0, skipped = 0;-- db.transaction(() => {++ await db.tx(async () => {for (const slug of slugs.slice(0, 500)) {-- const p = store.getBySlug(db, String(slug));++ const p = await store.getBySlug(db, String(slug));if (!p) { skipped++; continue; }-- if (action === 'delete') store.softDelete(db, p.id);-- else store.setVisibility(db, p.id, action);-- if (action !== 'public') tell((pl) => pl.pasteGone(p.slug));++ if (action === 'delete') await store.softDelete(db, p.id);++ else await store.setVisibility(db, p.id, action);++ if (action !== 'public') await tell((pl) => pl.pasteGone(p.slug));done++;}-- if (done) events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });-- })();++ if (done) await events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });++ });return { done, skipped };},/** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */async censor(v, slugOrId, file) {-- let p = store.getBySlug(db, String(slugOrId));-- if (!p && /^\d+$/.test(String(slugOrId))) p = store.getById(db, parseInt(slugOrId, 10));++ let p = await store.getBySlug(db, String(slugOrId));++ if (!p && /^\d+$/.test(String(slugOrId))) p = await store.getById(db, parseInt(slugOrId, 10));if (!p) fail(404, 'Paste not found');if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');if (!file || !file.buffer) fail(400, 'Censored image is required');@@@ -649,21 +649,21 @@ function createPasteService({ db, network = null, media = null, config = {}, limconsole.warn('[Pastes] censor upload failed:', err.message);fail(502, 'Media service unavailable');}-- const row = db.transaction(() => {-- const r = store.setScreenshot(db, p.id, stored.url, stored.media_ref);-- events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });++ const row = await db.tx(async () => {++ const r = await store.setScreenshot(db, p.id, stored.url, stored.media_ref);++ await events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });return r;-- })();++ });return { paste: await shapeOne(row, v) };},/** POST /api/pastes/:slug/ai — the AI pass writes its results back. */async setAi(v, slug, body = {}) {-- const p = store.getBySlug(db, String(slug));++ const p = await store.getBySlug(db, String(slug));if (!p) fail(404, 'Paste not found');const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);-- const row = store.setAi(db, p.id, summary, tags);++ const row = await store.setAi(db, p.id, summary, tags);return { ok: true, paste: await shapeOne(row, v) };},};@@@ -61,7 +61,7 @@ const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words* characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these* cannot. Existing slugs never change.*/--function generateSlug(db, { secret = false } = {}) {++async function generateSlug(db, { secret = false } = {}) {const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');const words = () => `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}`;if (secret) {@@@ -69,18 +69,18 @@ function generateSlug(db, { secret = false } = {}) {let tail = '';for (let j = 0; j < SECRET_CHARS; j++) tail += SECRET_ALPHABET[crypto.randomInt(SECRET_ALPHABET.length)];const slug = `${words()}-${tail}`;-- if (!taken.get(slug)) return slug;++ if (!await taken.get(slug)) return slug;}throw new Error('Could not generate a unique paste slug');}for (let i = 0; i < 10; i++) {const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(10, 100)}`;-- if (!taken.get(slug)) return slug;++ if (!await taken.get(slug)) return slug;}// ~180k combinations; if ten draws all collide, widen the number rather than fail.for (let i = 0; i < 10; i++) {const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, 10000)}`;-- if (!taken.get(slug)) return slug;++ if (!await taken.get(slug)) return slug;}throw new Error('Could not generate a unique paste slug');}@@@ -111,12 +111,12 @@ const likeEscape = (s) => String(s).replace(/[\\%_]/g, (c) => `\\${c}`);// ── Pastes ───────────────────────────────────────────────────--function getBySlug(db, slug) {-- return db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug)) || null;++async function getBySlug(db, slug) {++ return await db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug)) || null;}--function getById(db, id) {-- return db.prepare('SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL').get(id) || null;++async function getById(db, id) {++ return await db.prepare('SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL').get(id) || null;}const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',@@@ -124,15 +124,15 @@ const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'con'ai_summary', 'ai_tags', 'ai_analyzed_at'];/** Insert a new paste (fields already validated); returns the stored row. */--function insertPaste(db, fields) {++async function insertPaste(db, fields) {const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);-- return db.transaction(() => {-- const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)++ return await db.tx(async () => {++ const info = await db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')}) RETURNING id`).run(...cols.map((c) => fields[c]));-- const row = getById(db, info.lastInsertRowid);-- require('../events').pasteCreated(row); // community.paste.created, in this transaction++ const row = await getById(db, info.lastInsertRowid);++ await require('../events').pasteCreated(row); // community.paste.created, in this transactionreturn row;-- })();++ });}/**@@@ -141,9 +141,9 @@ function insertPaste(db, fields) {* edit also snapshots the original as revision 1, so the history is complete without storing a* copy of every paste that is never edited.*/--function updatePaste(db, id, patch, editedBy = null) {-- return db.transaction(() => {-- const cur = getById(db, id);++async function updatePaste(db, id, patch, editedBy = null) {++ return await db.tx(async () => {++ const cur = await getById(db, id);if (!cur) return null;const sets = [];const params = [];@@@ -154,39 +154,39 @@ function updatePaste(db, id, patch, editedBy = null) {}const textChanged = ['title', 'content', 'language'].some((k) => patch[k] !== undefined && patch[k] !== cur[k]);if (textChanged) {-- const hasCur = db.prepare('SELECT 1 FROM paste_versions WHERE paste_id = ? AND revision = ?').get(id, cur.revision);++ const hasCur = await db.prepare('SELECT 1 FROM paste_versions WHERE paste_id = ? AND revision = ?').get(id, cur.revision);if (!hasCur) {-- db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(id, cur.revision, cur.title, cur.content, cur.language, cur.owner_subject, cur.updated_at || cur.created_at);}sets.push('revision = revision + 1');}-- sets.push('updated_at = CURRENT_TIMESTAMP');-- db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);-- const next = getById(db, id);++ sets.push('updated_at = ov_now()');++ await db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);++ const next = await getById(db, id);if (textChanged) {-- db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)').run(id, next.revision, next.title, next.content, next.language, editedBy);}const changed = ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].filter((k) => patch[k] !== undefined && patch[k] !== cur[k]);-- if (changed.length) require('../events').pasteUpdated(next, changed);++ if (changed.length) await require('../events').pasteUpdated(next, changed);return next;-- })();++ });}--function listVersions(db, pasteId) {-- return db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);++async function listVersions(db, pasteId) {++ return await db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);}/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */--function softDelete(db, id) {-- return db.transaction(() => {-- const cur = getById(db, id);-- const n = db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,-- ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;-- if (n && cur) require('../events').pasteDeleted(cur.slug); // community.paste.deleted++async function softDelete(db, id) {++ return await db.tx(async () => {++ const cur = await getById(db, id);++ const n = (await db.prepare(`UPDATE pastes SET deleted_at = ov_now(), content = NULL, screenshot_url = NULL, metadata = NULL,++ ai_summary = NULL, ai_tags = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL`).run(id)).changes;++ if (n && cur) await require('../events').pasteDeleted(cur.slug); // community.paste.deletedreturn n;-- })();++ });}/**@@@ -203,7 +203,7 @@ function softDelete(db, id) {* with others by date or score turn it off so the order is the sort alone* Returns { rows, total }.*/--function listPastes(db, opts = {}) {++async function listPastes(db, opts = {}) {const where = ['deleted_at IS NULL'];const params = [];// The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.@@@ -216,7 +216,7 @@ function listPastes(db, opts = {}) {if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }if (opts.search) {const q = `%${likeEscape(opts.search)}%`;-- where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");++ where.push("(title ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')");params.push(q, q);}// datetime() on both sides: imported rows may carry ISO text, which must compare by time.@@@ -229,107 +229,107 @@ function listPastes(db, opts = {}) {const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);const clause = where.join(' AND ');-- const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY ${order} LIMIT ? OFFSET ?`)++ const rows = await db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY ${order} LIMIT ? OFFSET ?`).all(...params, limit, offset);-- const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);++ const { total } = await db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);return { rows, total };}/** Pastes created by a subject since `sinceSql` (an SQLite datetime modifier, e.g. '-1 day'). */--function countOwnerSince(db, subject, sinceSql) {-- return db.prepare("SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;++async function countOwnerSince(db, subject, sinceSql) {++ return (await db.prepare("SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;}/** ms timestamp of the subject's newest paste (deleted ones count: deleting doesn't reset a cooldown). */--function lastPasteTime(db, subject) {-- const row = db.prepare('SELECT created_at FROM pastes WHERE owner_subject = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(subject);++async function lastPasteTime(db, subject) {++ const row = await db.prepare('SELECT created_at FROM pastes WHERE owner_subject = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(subject);return row ? Date.parse(String(row.created_at).replace(' ', 'T') + 'Z') : 0;}--function hasLiked(db, pasteId, subject) {-- return !!db.prepare('SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?').get(pasteId, subject);++async function hasLiked(db, pasteId, subject) {++ return !!await db.prepare('SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?').get(pasteId, subject);}/** Toggle a like; the counter is recounted from the rows (as Media did). → { liked, likes } */--function toggleLike(db, pasteId, subject) {-- return db.transaction(() => {-- const already = hasLiked(db, pasteId, subject);-- if (already) db.prepare('DELETE FROM paste_likes WHERE paste_id = ? AND subject_id = ?').run(pasteId, subject);-- else db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id) VALUES (?, ?)').run(pasteId, subject);-- db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(pasteId, pasteId);-- return { liked: !already, likes: db.prepare('SELECT likes FROM pastes WHERE id = ?').get(pasteId).likes };-- })();++async function toggleLike(db, pasteId, subject) {++ return await db.tx(async () => {++ const already = await hasLiked(db, pasteId, subject);++ if (already) await db.prepare('DELETE FROM paste_likes WHERE paste_id = ? AND subject_id = ?').run(pasteId, subject);++ else await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (?, ?) ON CONFLICT DO NOTHING').run(pasteId, subject);++ await db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(pasteId, pasteId);++ return { liked: !already, likes: (await db.prepare('SELECT likes FROM pastes WHERE id = ?').get(pasteId)).likes };++ });}--function incrementCopies(db, pasteId) {-- db.prepare('UPDATE pastes SET copies = copies + 1 WHERE id = ?').run(pasteId);-- return db.prepare('SELECT copies FROM pastes WHERE id = ?').get(pasteId).copies;++async function incrementCopies(db, pasteId) {++ await db.prepare('UPDATE pastes SET copies = copies + 1 WHERE id = ?').run(pasteId);++ return (await db.prepare('SELECT copies FROM pastes WHERE id = ?').get(pasteId)).copies;}/** Burn-after-read pastes keep a literal every-read counter. Returns the new count. */--function bumpViews(db, pasteId) {-- db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);-- return db.prepare('SELECT views FROM pastes WHERE id = ?').get(pasteId).views;++async function bumpViews(db, pasteId) {++ await db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);++ return (await db.prepare('SELECT views FROM pastes WHERE id = ?').get(pasteId)).views;}/*** Visit-based view counting (Media's rules): a visitor's first visit counts as a view and a* unique view; later visits count again only after `cooldownSec`. → { counted, unique, views, unique_views }*/--function recordVisit(db, pasteId, visitor, cooldownSec) {-- return db.transaction(() => {-- const row = db.prepare('SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?').get(pasteId, visitor);++async function recordVisit(db, pasteId, visitor, cooldownSec) {++ return await db.tx(async () => {++ const row = await db.prepare('SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?').get(pasteId, visitor);let counted = false, unique = false;if (!row) {-- db.prepare('INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)').run(pasteId, visitor);-- db.prepare('UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?').run(pasteId);++ await db.prepare('INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)').run(pasteId, visitor);++ await db.prepare('UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?').run(pasteId);counted = true; unique = true;} else {const lastMs = Date.parse(String(row.last_at).replace(' ', 'T') + 'Z');if (!(cooldownSec > 0 && Date.now() - lastMs < cooldownSec * 1000)) {-- db.prepare('UPDATE paste_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE paste_id = ? AND visitor = ?').run(pasteId, visitor);-- db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);++ await db.prepare('UPDATE paste_visits SET last_at = ov_now(), visits = visits + 1 WHERE paste_id = ? AND visitor = ?').run(pasteId, visitor);++ await db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);counted = true;}}-- const c = db.prepare('SELECT views, unique_views FROM pastes WHERE id = ?').get(pasteId);++ const c = await db.prepare('SELECT views, unique_views FROM pastes WHERE id = ?').get(pasteId);return { counted, unique, views: c.views, unique_views: c.unique_views };-- })();++ });}--function pruneVisits(db, days = 30) {-- return db.prepare("DELETE FROM paste_visits WHERE last_at < datetime('now', ?)").run(`-${days} days`).changes;++async function pruneVisits(db, days = 30) {++ return (await db.prepare("DELETE FROM paste_visits WHERE last_at < datetime('now', ?)").run(`-${days} days`)).changes;}--function setAi(db, pasteId, summary, tags) {-- db.prepare('UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = CURRENT_TIMESTAMP WHERE id = ?').run(summary, tags, pasteId);-- return getById(db, pasteId);++async function setAi(db, pasteId, summary, tags) {++ await db.prepare('UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = ov_now() WHERE id = ?').run(summary, tags, pasteId);++ return await getById(db, pasteId);}--function setScreenshot(db, pasteId, url, mediaRef) {-- db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(url, mediaRef, pasteId);-- return getById(db, pasteId);++async function setScreenshot(db, pasteId, url, mediaRef) {++ await db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, updated_at = ov_now() WHERE id = ?').run(url, mediaRef, pasteId);++ return await getById(db, pasteId);}--function setVisibility(db, pasteId, visibility) {-- return db.transaction(() => {-- const cur = getById(db, pasteId);-- const r = db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);-- if (r.changes && cur && cur.visibility !== visibility) require('../events').pasteUpdated(getById(db, pasteId), ['visibility']);++async function setVisibility(db, pasteId, visibility) {++ return await db.tx(async () => {++ const cur = await getById(db, pasteId);++ const r = await db.prepare('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);++ if (r.changes && cur && cur.visibility !== visibility) await require('../events').pasteUpdated(await getById(db, pasteId), ['visibility']);return r.changes;-- })();++ });}/** Media's admin stats shape. */--function stats(db) {-- const r = db.prepare(`++async function stats(db) {++ const r = await db.prepare(`SELECT COUNT(*) AS total,-- SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS textPastes,-- SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots,-- SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks,-- COALESCE(SUM(views), 0) AS totalViews,-- COALESCE(SUM(copies), 0) AS totalCopies,-- COALESCE(SUM(likes), 0) AS totalLikes++ COUNT(*) FILTER (WHERE type = 'paste') AS "textPastes",++ COUNT(*) FILTER (WHERE type = 'screenshot') AS screenshots,++ COUNT(*) FILTER (WHERE forked_from IS NOT NULL) AS forks,++ COALESCE(SUM(views), 0)::bigint AS "totalViews",++ COALESCE(SUM(copies), 0)::bigint AS "totalCopies",++ COALESCE(SUM(likes), 0)::bigint AS "totalLikes"FROM pastes WHERE deleted_at IS NULL`).get() || {};return {total: r.total || 0, textPastes: r.textPastes || 0, screenshots: r.screenshots || 0, forks: r.forks || 0,@@@ -337,72 +337,72 @@ function stats(db) {};}--function listForks(db, limit, offset) {-- const forks = db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at++async function listForks(db, limit, offset) {++ const forks = await db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_atFROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULLORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(limit, offset);-- const total = db.prepare('SELECT COUNT(*) AS c FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').get().c;++ const total = (await db.prepare('SELECT COUNT(*) AS c FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').get()).c;return { forks, total };}--function deleteAllForks(db) {-- return db.transaction(() => {-- const ids = db.prepare('SELECT id FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').all().map((r) => r.id);-- for (const id of ids) softDelete(db, id);++async function deleteAllForks(db) {++ return await db.tx(async () => {++ const ids = (await db.prepare('SELECT id FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').all()).map((r) => r.id);++ for (const id of ids) await softDelete(db, id);return ids.length;-- })();++ });}// ── Comments ─────────────────────────────────────────────────--function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {-- const info = db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?)')++async function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {++ const info = await db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?) RETURNING id').run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);-- return getComment(db, info.lastInsertRowid);++ return await getComment(db, info.lastInsertRowid);}--function getComment(db, id) {-- return db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;++async function getComment(db, id) {++ return await db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;}/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */--function listComments(db, pasteId, limit = 50, offset = 0) {-- const top = db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL++async function listComments(db, pasteId, limit = 50, offset = 0) {++ const top = await db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULLORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');-- for (const c of top) { c.replies = replies.all(c.id); c.reply_count = c.replies.length; }++ for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }return top;}--function countComments(db, pasteId) {-- return db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId).c;++async function countComments(db, pasteId) {++ return (await db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId)).c;}--function softDeleteComment(db, id) {-- return db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id).changes;++async function softDeleteComment(db, id) {++ return (await db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = ov_now() WHERE id = ?').run(id)).changes;}// ── Subject projection (display cache — never authority) ─────--function getProjections(db, subjectIds) {++async function getProjections(db, subjectIds) {const out = new Map();const ids = [...new Set((subjectIds || []).filter(Boolean))];const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');-- for (const id of ids) { const r = stmt.get(id); if (r) out.set(id, r); }++ for (const id of ids) { const r = await stmt.get(id); if (r) out.set(id, r); }return out;}/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */--function upsertProjection(db, p) {++async function upsertProjection(db, p) {if (!p || !p.subject_id) return;-- db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)-- VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, CURRENT_TIMESTAMP)++ await db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)++ VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, ov_now())ON CONFLICT(subject_id) DO UPDATE SET-- username = COALESCE(excluded.username, username),-- display_name = COALESCE(excluded.display_name, display_name),-- avatar_url = COALESCE(excluded.avatar_url, avatar_url),-- profile_color = COALESCE(excluded.profile_color, profile_color),-- refreshed_at = CURRENT_TIMESTAMP`)++ username = COALESCE(excluded.username, subject_projection.username),++ display_name = COALESCE(excluded.display_name, subject_projection.display_name),++ avatar_url = COALESCE(excluded.avatar_url, subject_projection.avatar_url),++ profile_color = COALESCE(excluded.profile_color, subject_projection.profile_color),++ refreshed_at = ov_now()`).run({subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,@@@ -410,20 +410,20 @@ function upsertProjection(db, p) {}/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */--function subjectsByUsername(db, username) {-- return db.prepare('SELECT subject_id FROM subject_projection WHERE username = ? COLLATE NOCASE ORDER BY refreshed_at DESC')-- .all(String(username)).map((r) => r.subject_id);++async function subjectsByUsername(db, username) {++ return (await db.prepare('SELECT subject_id FROM subject_projection WHERE lower(username) = lower(?) ORDER BY refreshed_at DESC')++ .all(String(username))).map((r) => r.subject_id);}// ── Legacy id map ────────────────────────────────────────────--function mapGet(db, system, type, id) {-- return db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')++async function mapGet(db, system, type, id) {++ return await db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?').get(String(system), String(type), String(id)) || null;}--function mapSet(db, system, type, id, targetType, targetId) {-- db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)++async function mapSet(db, system, type, id, targetType, targetId) {++ await db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`).run(String(system), String(type), String(id), String(targetType), String(targetId));}@@@ -28,9 +28,9 @@ function createPulseApi({ pulse, viewers, limits }) {const servicesOnly = (req, res, next) => (req.viewer.kind === 'service' ? next(): contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `a service token with ${WRITE} is required`, ctx: req.ov }));-- router.get('/', run((req) => pulse.list(req.query)));-- router.post('/items', servicesOnly, serviceCap(WRITE), ingest, jsonBody, run((req) => pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));-- router.delete('/items/:service/:type/:id', servicesOnly, serviceCap(WRITE), ingest, run((req) => pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));++ router.get('/', run(async (req) => await pulse.list(req.query)));++ router.post('/items', servicesOnly, serviceCap(WRITE), ingest, jsonBody, run(async (req) => await pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));++ router.delete('/items/:service/:type/:id', servicesOnly, serviceCap(WRITE), ingest, run(async (req) => await pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;@@@ -17,7 +17,7 @@* that go down or stay put announce nothing; only the public fields are read.** And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token-- * cutoff (openvibe-sdk createRevocationStore); the viewer resolver refuses their older tokens.++ * cutoff (openvibe-sdk createPgRevocationStore); the viewer resolver refuses their older tokens.** And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only* answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.@@@ -32,7 +32,7 @@*/const express = require('express');const { http, serviceAuth } = require('openvibe-contracts');--const { parseDelivery, createInbox } = require('openvibe-sdk/events');++const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const store = require('./store');const blocks = require('../identity/blocks');@@@ -88,25 +88,25 @@ function gameProgressOf(event) {}/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */--function applyGameProgress(db, g) {-- const prev = db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);-- db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)-- ON CONFLICT(subject_id) DO UPDATE SET level = MAX(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);++async function applyGameProgress(db, g) {++ const prev = await db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);++ await db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)++ ON CONFLICT(subject_id) DO UPDATE SET level = GREATEST(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);if (!prev) return 'games:baseline';const milestone = Math.floor(g.level / GAME_MILESTONE) * GAME_MILESTONE;if (milestone <= prev.level || milestone < GAME_MILESTONE) return 'games:no_milestone';-- const r = store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,++ const r = await store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,url: GAME_URL, actor_subject: g.subject, origin: 'user', occurred_at: g.at });return r.created ? 'pulse:created' : 'pulse:updated';}function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);-- const inbox = createInbox(db, { table: 'community_event_inbox', now });-- inbox.ensureSchema();++ // Receipts (community_event_inbox) are in migrations/0001_initial.sql.++ const inbox = createPgInbox(db, { table: 'community_event_inbox', now });const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };const router = express.Router();-- router.post('/', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {++ router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {const ctx = http.requestContext(req.headers);const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'community.internal_only', 'internal route');@@@ -120,7 +120,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =stats.received++; stats.last_at = new Date(now()).toISOString();if (event.event_type === 'network.user.token_valid_after') {if (!revocations) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_store' }); }-- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: revocations.apply(event) }));++ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await revocations.apply(event) }));if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });}@@@ -128,7 +128,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =const p = blocks.payloadOf(event);if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }try {-- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: blocks.apply(db, p, now()) }));++ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await blocks.apply(db, p, now()) }));if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });} catch (err) {@@@ -156,7 +156,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =const p = merge.payloadOf(event);if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }try {-- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: merge.apply(db, p, { log }) }));++ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await merge.apply(db, p, { log }) }));if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });} catch (err) {@@@ -167,7 +167,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =}if (event.event_type === 'vip.membership.changed') {if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }-- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));++ const r = await inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });}@@@ -175,7 +175,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =const g = gameProgressOf(event);if (typeof g === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: g }); }try {-- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: applyGameProgress(db, g) }));++ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await applyGameProgress(db, g) }));if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });} catch (err) {@@@ -187,7 +187,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =const item = itemFor(event);if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }try {-- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: store.upsertItem(db, item).created ? 'pulse:created' : 'pulse:updated' }));++ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: (await store.upsertItem(db, item)).created ? 'pulse:created' : 'pulse:updated' }));if (r.duplicate) stats.duplicates++; else stats.applied++;return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });} catch (err) {@@@ -33,18 +33,18 @@ function createPulse({ db, network = null, config = {} } = {}) {const base = (config.baseUrl || '').replace(/\/$/, '');/** Record one of Community's own items; never lets a Pulse problem fail the write that caused it. */-- function recordLocal(type, id, { title, path, actor = null, origin = 'user', at = null }) {++ async function recordLocal(type, id, { title, path, actor = null, origin = 'user', at = null }) {try {const o = ORIGINS.includes(origin) ? origin : 'user';-- store.upsertItem(db, {++ await store.upsertItem(db, {source_service: 'community', source_type: type, source_id: String(id),title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,actor_subject: o === 'user' ? actor : null, origin: o, occurred_at: at || sqlTime(),});} catch (err) { console.warn('[Pulse] record failed:', err.message); }}-- function forgetLocal(type, id) {-- try { store.removeItem(db, 'community', type, id); } catch (err) { console.warn('[Pulse] remove failed:', err.message); }++ async function forgetLocal(type, id) {++ try { await store.removeItem(db, 'community', type, id); } catch (err) { console.warn('[Pulse] remove failed:', err.message); }}function shape(i, projections) {@@@ -66,31 +66,31 @@ function createPulse({ db, network = null, config = {} } = {}) {ORIGINS,// ── hooks for Community's own writes ─────────────────-- pasteCreated(p) {++ async pasteCreated(p) {if (!p || p.visibility !== 'public' || p.origin !== 'user' || Number(p.burn_after_read) || Number(p.is_nsfw)) return;-- recordLocal('paste', p.slug, { title: p.title, path: `/p/${encodeURIComponent(p.slug)}`, actor: p.owner_subject, origin: 'user', at: p.created_at });++ await recordLocal('paste', p.slug, { title: p.title, path: `/p/${encodeURIComponent(p.slug)}`, actor: p.owner_subject, origin: 'user', at: p.created_at });},-- pasteChanged(p) {++ async pasteChanged(p) {if (!p) return;-- if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) forgetLocal('paste', p.slug);++ if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) await forgetLocal('paste', p.slug);},-- pasteGone(slug) { forgetLocal('paste', slug); },-- threadCreated(thread, space) {++ async pasteGone(slug) { await forgetLocal('paste', slug); },++ async threadCreated(thread, space) {if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;-- recordLocal('thread', thread.id, {++ await recordLocal('thread', thread.id, {title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,});},-- postCreated(post, thread, space) {++ async postCreated(post, thread, space) {if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;-- recordLocal('post', post.id, {++ await recordLocal('post', post.id, {title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,});},-- threadGone(id) { forgetLocal('thread', id); },-- postGone(id) { forgetLocal('post', id); },++ async threadGone(id) { await forgetLocal('thread', id); },++ async postGone(id) { await forgetLocal('post', id); },/** POST /api/v1/pulse/items (a service with community.pulse.write). → { item, created } */async ingest(v, body = {}) {@@@ -110,7 +110,7 @@ function createPulse({ db, network = null, config = {} } = {}) {if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwiseconst when = body.occurred_at == null ? new Date() : new Date(body.occurred_at);if (Number.isNaN(when.getTime()) || when.getTime() > Date.now() + FUTURE_SKEW_MS || when.getUTCFullYear() < 2000) fail(400, 'pulse.invalid_time', 'occurred_at must be an ISO time, not in the future');-- const out = store.upsertItem(db, {++ const out = await store.upsertItem(db, {source_service: ref.service, source_type: ref.type, source_id: ref.id,title, url: parsed.toString(),actor_subject: origin === 'user' ? (v.subject || null) : null,@@@ -121,9 +121,9 @@ function createPulse({ db, network = null, config = {} } = {}) {},/** DELETE /api/v1/pulse/items/:service/:type/:id — a service retracting its own item. */-- retract(v, service, type, id) {++ async retract(v, service, type, id) {if (service !== serviceNameOf(v.service)) fail(403, 'pulse.foreign_ref', 'A service retracts only its own items');-- return { removed: store.removeItem(db, service, type, id) };++ return { removed: await store.removeItem(db, service, type, id) };},/** GET /api/v1/pulse ?origin=user|ai|system&after=<cursor>&limit= → { items, next_cursor } */@@@ -132,7 +132,7 @@ function createPulse({ db, network = null, config = {} } = {}) {if (origin && !ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);const before = decodeCursor(q.after, 2);-- const { rows, hasMore } = store.listItems(db, { origin, before, limit });++ const { rows, hasMore } = await store.listItems(db, { origin, before, limit });const projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));const last = rows[rows.length - 1];return { items: rows.map((r) => shape(r, projections)), next_cursor: hasMore && last ? encodeCursor([last.occurred_at, last.id]) : null };@@@ -9,22 +9,22 @@*//** → { item, created } */--function upsertItem(db, it) {++async function upsertItem(db, it) {const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');-- return db.transaction(() => {++ return await db.tx(async () => {const key = [it.source_service, it.source_type, String(it.source_id)];-- if (find.get(...key)) {-- db.prepare('UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, ...key);-- return { item: find.get(...key), created: false };++ if (await find.get(...key)) {++ await db.prepare('UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, ...key);++ return { item: await find.get(...key), created: false };}-- db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)++ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at);-- return { item: find.get(...key), created: true };-- })();++ return { item: await find.get(...key), created: true };++ });}--function removeItem(db, service, type, id) {-- return db.prepare('DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').run(service, type, String(id)).changes;++async function removeItem(db, service, type, id) {++ return (await db.prepare('DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').run(service, type, String(id))).changes;}/**@@@ -35,11 +35,11 @@ function removeItem(db, service, type, id) {* opts.before [occurred_at, id] of the last item already seen* → { rows, hasMore }*/--function listItems(db, { origin = null, before = null, limit = 30 } = {}) {-- const rows = db.prepare(`++async function listItems(db, { origin = null, before = null, limit = 30 } = {}) {++ const rows = await db.prepare(`SELECT i.* FROM pulse_items i-- WHERE (@origin IS NULL OR i.origin = @origin)-- AND (@at IS NULL OR i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id))++ WHERE (@origin::text IS NULL OR i.origin = @origin)++ AND (@at::text IS NULL OR i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id::bigint))AND (i.source_service <> 'community'OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id@@@ -54,44 +54,44 @@ function createRelayApi({ relay, db, viewers, inbound = null, limits }) {return out;}-- router.get('/status', run(() => relay.status()));-- router.get('/deliveries', run((req) => {++ router.get('/status', run(async () => await relay.status()));++ router.get('/deliveries', run(async (req) => {const status = DELIVERY_STATUSES.includes(req.query.status) ? req.query.status : null;const action = ['create', 'edit', 'delete'].includes(req.query.action) ? req.query.action : null;-- return { enabled: relay.enabled, deliveries: relay.listDeliveries({ status, action, limit: intIn(req.query.limit, 50, 1, 200) }) };++ return { enabled: relay.enabled, deliveries: await relay.listDeliveries({ status, action, limit: intIn(req.query.limit, 50, 1, 200) }) };}));-- router.post('/deliveries/:id/retry', manage, run((req) => {-- if (!relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');++ router.post('/deliveries/:id/retry', manage, run(async (req) => {++ if (!await relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');return { ok: true };}));-- router.post('/deliveries/:id/drop', manage, run((req) => {-- if (!relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');++ router.post('/deliveries/:id/drop', manage, run(async (req) => {++ if (!await relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');return { ok: true };}));-- router.get('/inbound', run((req) => ({-- enabled: !!inbound, failures: inbound ? inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],++ router.get('/inbound', run(async (req) => ({++ enabled: !!inbound, failures: inbound ? await inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],})));-- router.post('/inbound/:id/dismiss', manage, run((req) => {-- if (!inbound || !inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');++ router.post('/inbound/:id/dismiss', manage, run(async (req) => {++ if (!inbound || !await inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');return { ok: true };}));-- router.get('/mappings', run(() => ({ enabled: relay.enabled, mappings: relay.listMappings() })));-- router.post('/mappings', manage, jsonBody, run((req) => {++ router.get('/mappings', run(async () => ({ enabled: relay.enabled, mappings: await relay.listMappings() })));++ router.post('/mappings', manage, jsonBody, run(async (req) => {const b = req.body || {};-- const space = forumStore.getSpace(db, String(b.space || ''));++ const space = await forumStore.getSpace(db, String(b.space || ''));if (!space) fail(400, 'relay.invalid_space', 'Unknown space');const ref = String(b.webhook_url_ref || '');if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');const ids = discordIds(b);-- return { mapping: relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false, ...ids, inbound: b.inbound === undefined ? undefined : !!b.inbound }) };++ return { mapping: await relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false, ...ids, inbound: b.inbound === undefined ? undefined : !!b.inbound }) };}, 201));-- router.put('/mappings/:id', manage, jsonBody, run((req) => {++ router.put('/mappings/:id', manage, jsonBody, run(async (req) => {const b = req.body || {};const fields = { ...discordIds(b) };if (b.enabled !== undefined) fields.enabled = !!b.enabled;if (b.inbound !== undefined) fields.inbound = !!b.inbound;-- const m = relay.updateMapping(idOf(req), fields);++ const m = await relay.updateMapping(idOf(req), fields);if (!m) fail(404, 'relay.mapping_not_found', 'No such mapping');return { mapping: m };}));@@@ -110,6 +110,8 @@ function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = gsock.addEventListener('error', () => { /* a close event follows */ });}++ let dispatching = Promise.resolve();++ let pendingDispatches = 0;function onMessage(raw) {let p;try { p = JSON.parse(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8')); } catch { return; }@@@ -161,12 +163,13 @@ function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = g}if (!DISPATCH.has(t)) return;state.dispatched++;-- try {-- const r = onDispatch(t, d || {}, { botUserId: state.bot_user_id });-- if (r && typeof r.catch === 'function') r.catch((err) => console.warn(`[Relay] inbound ${t} failed:`, err.message));-- } catch (err) {-- console.warn(`[Relay] inbound ${t} failed:`, err.message);-- }++ // One at a time, in arrival order: a MESSAGE_UPDATE never overtakes its MESSAGE_CREATE (handling is async).++ // Idle, the handler starts at once; otherwise the event waits behind the one in progress.++ const ctx = { botUserId: state.bot_user_id };++ const go = () => { try { return Promise.resolve(onDispatch(t, d || {}, ctx)); } catch (err) { return Promise.reject(err); } };++ const run = pendingDispatches === 0 ? go() : dispatching.then(go);++ pendingDispatches++;++ dispatching = run.catch((err) => console.warn(`[Relay] inbound ${t} failed:`, err.message)).finally(() => { pendingDispatches--; });}function onClose(code, why) {@@@ -56,7 +56,7 @@ const DELIVERY_STATUSES = ['pending', 'delivered', 'failed', 'dropped', 'skippedconst escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');/** webhookVars: exact allowed names, or null/empty for the DISCORD_WEBHOOK_* default. */--function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, now = () => Date.now(), webhookVars = null } = {}) {++function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, kickMs = 250, now = () => Date.now(), webhookVars = null } = {}) {const base = (config.baseUrl || '').replace(/\/$/, '');const allowed = Array.isArray(webhookVars) && webhookVars.length ? new Set(webhookVars) : null;/** May a mapping name this variable? */@@@ -64,6 +64,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gllet timer = null;let draining = null;let stopped = false;++ let kickTimer = null;// While the Events worker runs it queues every create from the event stream; the forum's direct// calls then queue nothing (the dedupe keys would make them harmless anyway).let createsFromEvents = false;@@@ -79,11 +80,11 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glconst insertStmt = db.prepare(`INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, action, dedupe_key, source, event_id, next_attempt_at)VALUES (@thread_id, @post_id, @mapping_id, @action, @key, @source, @event_id, @at) ON CONFLICT(dedupe_key) DO NOTHING`);-- function insert(row) {-- return insertStmt.run({ post_id: null, source: 'direct', event_id: null, ...row, at: sqlTime(now()) }).changes;++ async function insert(row) {++ return (await insertStmt.run({ post_id: null, source: 'direct', event_id: null, ...row, at: sqlTime(now()) })).changes;}-- const outMappings = (spaceId) => db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1 ORDER BY id").all(spaceId);-- const mapRow = (mappingId, type, id) => db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND mapping_id = ? AND local_type = ? AND local_id = ?").get(mappingId, type, id) || null;++ const outMappings = async (spaceId) => await db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1 ORDER BY id").all(spaceId);++ const mapRow = async (mappingId, type, id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND mapping_id = ? AND local_type = ? AND local_id = ?").get(mappingId, type, id) || null;/** Why this thread (and a post of it) may not leave the site, or null. */function refusal(thread, space, post = null) {@@@ -99,13 +100,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl* A new thread, for every enabled out-mapping of its space. source 'events' (the Events worker,* eventId) or 'direct' (the forum; ignored while the worker queues creates). → number queued*/-- function enqueueThread(thread, space, { source = 'direct', eventId = null } = {}) {++ async function enqueueThread(thread, space, { source = 'direct', eventId = null } = {}) {if (!enabled || !thread || !space) return 0;if (source === 'direct' && createsFromEvents) return 0;if (!mayLeave(thread, space)) return 0;let queued = 0;-- for (const m of outMappings(space.id)) {-- queued += insert({ thread_id: thread.id, mapping_id: m.id, action: 'create', key: keys.create('thread', thread.id, m.id), source, event_id: eventId });++ for (const m of await outMappings(space.id)) {++ queued += await insert({ thread_id: thread.id, mapping_id: m.id, action: 'create', key: keys.create('thread', thread.id, m.id), source, event_id: eventId });}if (queued) kick();return queued;@@@ -115,15 +116,15 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl* A new reply: to every mapping its thread went to (sent, or still waiting to be). The opening post* travels with its thread. → number queued*/-- function enqueuePost(post, thread, space, { source = 'direct', eventId = null } = {}) {++ async function enqueuePost(post, thread, space, { source = 'direct', eventId = null } = {}) {if (!enabled || !post || !thread || !space || post.is_opening) return 0;if (source === 'direct' && createsFromEvents) return 0;if (!mayLeave(thread, space, post)) return 0;let queued = 0;-- for (const m of outMappings(space.id)) {-- const parent = db.prepare("SELECT status FROM relay_deliveries WHERE dedupe_key = ? AND status IN ('pending', 'delivered')").get(keys.create('thread', thread.id, m.id));-- if (!parent && !mapRow(m.id, 'thread', thread.id)) continue;-- queued += insert({ thread_id: thread.id, post_id: post.id, mapping_id: m.id, action: 'create', key: keys.create('post', post.id, m.id), source, event_id: eventId });++ for (const m of await outMappings(space.id)) {++ const parent = await db.prepare("SELECT status FROM relay_deliveries WHERE dedupe_key = ? AND status IN ('pending', 'delivered')").get(keys.create('thread', thread.id, m.id));++ if (!parent && !await mapRow(m.id, 'thread', thread.id)) continue;++ queued += await insert({ thread_id: thread.id, post_id: post.id, mapping_id: m.id, action: 'create', key: keys.create('post', post.id, m.id), source, event_id: eventId });}if (queued) kick();return queued;@@@ -133,14 +134,14 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl* An edited post: the Discord message showing it (the thread's, for an opening post) is edited.* Only what is already on Discord; a create still waiting carries the new text anyway.*/-- function enqueueEdit(post) {++ async function enqueueEdit(post) {if (!enabled || !post) return 0;const type = post.is_opening ? 'thread' : 'post';const id = post.is_opening ? post.thread_id : post.id;-- const rows = db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND direction = 'out' AND local_type = ? AND local_id = ? AND external_deleted_at IS NULL").all(type, id);++ const rows = await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND direction = 'out' AND local_type = ? AND local_id = ? AND external_deleted_at IS NULL").all(type, id);let queued = 0;for (const r of rows) {-- queued += insert({ thread_id: post.thread_id, post_id: post.is_opening ? null : post.id, mapping_id: r.mapping_id, action: 'edit', key: keys.edit(type, id, r.mapping_id, post.revision) });++ queued += await insert({ thread_id: post.thread_id, post_id: post.is_opening ? null : post.id, mapping_id: r.mapping_id, action: 'edit', key: keys.edit(type, id, r.mapping_id, post.revision) });}if (queued) kick();return queued;@@@ -151,58 +152,66 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl* moderated away or gated members-only: what is still waiting is skipped and what Discord shows is* deleted. Queued even while the relay is off, so it happens once it is on again.*/-- function enqueueDelete(threadId, postId = null, reason = 'deleted') {++ async function enqueueDelete(threadId, postId = null, reason = 'deleted') {const note = `skipped: ${reason} before it was sent`;-- return db.transaction(() => {++ return await db.tx(async () => {if (postId == null) {-- db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE thread_id = ? AND status = 'pending' AND action <> 'delete'").run(note, threadId);++ await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE thread_id = ? AND status = 'pending' AND action <> 'delete'").run(note, threadId);} else {-- db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE post_id = ? AND status = 'pending' AND action <> 'delete'").run(note, postId);++ await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE post_id = ? AND status = 'pending' AND action <> 'delete'").run(note, postId);}const rows = postId == null-- ? db.prepare("SELECT * FROM relay_message_map WHERE thread_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(threadId)-- : db.prepare("SELECT * FROM relay_message_map WHERE local_type = 'post' AND local_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(postId);++ ? await db.prepare("SELECT * FROM relay_message_map WHERE thread_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(threadId)++ : await db.prepare("SELECT * FROM relay_message_map WHERE local_type = 'post' AND local_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(postId);let queued = 0;for (const r of rows) {-- queued += insert({ thread_id: r.thread_id, post_id: r.local_type === 'post' ? r.local_id : null, mapping_id: r.mapping_id, action: 'delete', key: keys.remove(r.local_type, r.local_id, r.mapping_id) });++ queued += await insert({ thread_id: r.thread_id, post_id: r.local_type === 'post' ? r.local_id : null, mapping_id: r.mapping_id, action: 'delete', key: keys.remove(r.local_type, r.local_id, r.mapping_id) });}if (queued && enabled) kick();return queued;-- })();++ });}/** Threads gated members-only (or otherwise no longer public): take them off Discord. */-- function hideThreads(threadIds, reason = 'made members-only') {++ async function hideThreads(threadIds, reason = 'made members-only') {let n = 0;-- for (const id of threadIds) n += enqueueDelete(id, null, reason);++ for (const id of threadIds) n += await enqueueDelete(id, null, reason);return n;}-- function kick() { setImmediate(() => { drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }); }++ // A drain soon after the change commits (never before: it would not see the row), kickMs later so that changes made++ // together (an edit and the delete right after it) go out as one pass, the edit skipped.++ function kick() {++ db.afterCommit(() => {++ if (kickTimer || stopped) return;++ kickTimer = setTimeout(() => { kickTimer = null; drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }, kickMs);++ if (kickTimer.unref) kickTimer.unref();++ });++ }// ── messages ─────────────────────────────────────────────-- function authorName(subject, origin) {++ async function authorName(subject, origin) {if (origin === 'ai') return AI_DISPLAY_NAME;if (origin === 'system' && !subject) return 'OpenVibe';if (subject) {-- const p = pasteStore.getProjections(db, [subject]).get(subject);++ const p = (await pasteStore.getProjections(db, [subject])).get(subject);if (p) return p.display_name || p.username || 'someone';}return 'someone';}const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;-- function postUrl(post, thread, space) {-- const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;++ async function postUrl(post, thread, space) {++ const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);return `${threadUrl(thread, space)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}`;}/** The thread's message: who started it, the title, an excerpt of the opening post, the link. */-- function message(thread, space) {-- const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);++ async function message(thread, space) {++ const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);const url = threadUrl(thread, space);return {username: 'OpenVibe.Community',-- content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(authorName(thread.author_subject, thread.origin))}: <${url}>`,++ content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,allowed_mentions: { parse: [] },embeds: [{title: String(thread.title).slice(0, 256),@@@ -215,12 +224,12 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl}/** A reply's message: who replied to which thread, an excerpt, the link to the post. */-- function postMessage(post, thread, space) {-- const url = postUrl(post, thread, space);++ async function postMessage(post, thread, space) {++ const url = await postUrl(post, thread, space);const title = String(thread.title).slice(0, 200);return {username: 'OpenVibe.Community',-- content: `${escapeDiscord(authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,++ content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,allowed_mentions: { parse: [] },embeds: [{title: `Re: ${title}`.slice(0, 256),@@@ -239,67 +248,67 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl}/** One send happened: delivered, retried later, or failed (the dead letter). */-- function record(d, { ok, status = null, error = null, retry = false, retryAfter = 0, note = null }) {++ async function record(d, { ok, status = null, error = null, retry = false, retryAfter = 0, note = null }) {const attempts = d.attempts + 1;if (ok) {-- db.prepare("UPDATE relay_deliveries SET status = 'delivered', attempts = ?, last_status = ?, last_error = ?, delivered_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")++ await db.prepare("UPDATE relay_deliveries SET status = 'delivered', attempts = ?, last_status = ?, last_error = ?, delivered_at = ov_now(), updated_at = ov_now() WHERE id = ?").run(attempts, status, note, d.id);return 'delivered';}const giveUp = !retry || attempts >= maxAttempts;-- db.prepare('UPDATE relay_deliveries SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')++ await db.prepare('UPDATE relay_deliveries SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = ov_now() WHERE id = ?').run(giveUp ? 'failed' : 'pending', attempts, status, String(error || '').slice(0, 500), sqlTime(now() + (giveUp ? 0 : backoff(attempts, retryAfter))), d.id);return giveUp ? 'failed' : 'retry';}/** Nothing was sent: already done (delivered) or nothing left to do (skipped). */-- function settle(d, status, reason) {-- db.prepare(`UPDATE relay_deliveries SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP,-- delivered_at = CASE WHEN ? = 'delivered' THEN CURRENT_TIMESTAMP ELSE delivered_at END WHERE id = ?`).run(status, reason, status, d.id);++ async function settle(d, status, reason) {++ await db.prepare(`UPDATE relay_deliveries SET status = ?, last_error = ?, updated_at = ov_now(),++ delivered_at = CASE WHEN ? = 'delivered' THEN ov_now() ELSE delivered_at END WHERE id = ?`).run(status, reason, status, d.id);return status === 'delivered' ? 'delivered' : 'skipped';}/** Not yet (a reply waiting for its thread's message): later, without using an attempt. */-- function defer(d, untilSql, reason) {++ async function defer(d, untilSql, reason) {const until = Math.max(Date.parse(`${String(untilSql || '').replace(' ', 'T')}Z`) || 0, now() + 1000);-- db.prepare('UPDATE relay_deliveries SET next_attempt_at = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(sqlTime(until), reason, d.id);++ await db.prepare('UPDATE relay_deliveries SET next_attempt_at = ?, last_error = ?, updated_at = ov_now() WHERE id = ?').run(sqlTime(until), reason, d.id);return 'retry';}async function send(d) {-- const m = db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(d.mapping_id);-- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(d.thread_id);-- const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;-- const post = d.post_id ? forumStore.getPost(db, d.post_id) : null;++ const m = await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(d.mapping_id);++ const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(d.thread_id);++ const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;++ const post = d.post_id ? await forumStore.getPost(db, d.post_id) : null;const type = d.post_id ? 'post' : 'thread';const localId = d.post_id || d.thread_id;const gone = !thread || thread.deleted_at || !space || (d.post_id && (!post || post.deleted_at));-- const mapped = mapRow(d.mapping_id, type, localId);++ const mapped = await mapRow(d.mapping_id, type, localId);if (d.action === 'create') {-- if (gone) return settle(d, 'skipped', `the ${type} was deleted before it was sent`);++ if (gone) return await settle(d, 'skipped', `the ${type} was deleted before it was sent`);const why = refusal(thread, space, post);-- if (why) return record(d, { ok: false, error: why });-- if (mapped) return settle(d, 'delivered', 'already on Discord');-- if (type === 'post' && !mapRow(d.mapping_id, 'thread', thread.id)) {-- const parent = db.prepare('SELECT status, next_attempt_at FROM relay_deliveries WHERE dedupe_key = ?').get(keys.create('thread', thread.id, d.mapping_id));-- if (parent && parent.status === 'pending') return defer(d, parent.next_attempt_at, "waiting for the thread's own message");-- return settle(d, 'skipped', 'the thread is not on Discord through this mapping');++ if (why) return await record(d, { ok: false, error: why });++ if (mapped) return await settle(d, 'delivered', 'already on Discord');++ if (type === 'post' && !await mapRow(d.mapping_id, 'thread', thread.id)) {++ const parent = await db.prepare('SELECT status, next_attempt_at FROM relay_deliveries WHERE dedupe_key = ?').get(keys.create('thread', thread.id, d.mapping_id));++ if (parent && parent.status === 'pending') return await defer(d, parent.next_attempt_at, "waiting for the thread's own message");++ return await settle(d, 'skipped', 'the thread is not on Discord through this mapping');}} else {-- if (!mapped || mapped.direction !== 'out') return settle(d, 'skipped', 'not on Discord');-- if (mapped.external_deleted_at) return settle(d, 'skipped', 'already deleted on Discord');++ if (!mapped || mapped.direction !== 'out') return await settle(d, 'skipped', 'not on Discord');++ if (mapped.external_deleted_at) return await settle(d, 'skipped', 'already deleted on Discord');if (d.action === 'edit') {-- if (gone) return settle(d, 'skipped', `the ${type} was deleted`);++ if (gone) return await settle(d, 'skipped', `the ${type} was deleted`);const why = refusal(thread, space, post);-- if (why) return settle(d, 'skipped', why);++ if (why) return await settle(d, 'skipped', why);}}-- if (!m || !refAllowed(m.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });++ if (!m || !refAllowed(m.webhook_url_ref)) return await record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });const raw = env[m.webhook_url_ref];-- if (!raw) return record(d, { ok: false, retry: true, error: `webhook URL variable ${m.webhook_url_ref} is not set` });++ if (!raw) return await record(d, { ok: false, retry: true, error: `webhook URL variable ${m.webhook_url_ref} is not set` });let url = null;try { url = new URL(raw); } catch { /* below */ }-- if (!url || !/^https?:$/.test(url.protocol)) return record(d, { ok: false, error: `${m.webhook_url_ref} is not an http(s) URL` });++ if (!url || !/^https?:$/.test(url.protocol)) return await record(d, { ok: false, error: `${m.webhook_url_ref} is not an http(s) URL` });let method = 'POST';let body = null;@@@ -308,13 +317,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glthreadTarget = (SNOWFLAKE.test(m.discord_thread_id || '') ? m.discord_thread_id : null) || url.searchParams.get('thread_id') || null;url.searchParams.set('wait', 'true');if (threadTarget) url.searchParams.set('thread_id', threadTarget);-- body = type === 'thread' ? message(thread, space) : postMessage(post, thread, space);++ body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);} else {method = d.action === 'edit' ? 'PATCH' : 'DELETE';url.pathname = `${url.pathname.replace(/\/+$/, '')}/messages/${mapped.external_message_id}`;url.search = '';if (mapped.external_thread_id) url.searchParams.set('thread_id', mapped.external_thread_id);-- if (d.action === 'edit') body = type === 'thread' ? message(thread, space) : postMessage(post, thread, space);++ if (d.action === 'edit') body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);}let res;@@@ -326,41 +335,41 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glsignal: AbortSignal.timeout(SEND_TIMEOUT_MS),});} catch (err) {-- return record(d, { ok: false, retry: true, error: err.name === 'TimeoutError' ? 'timed out' : err.message });++ return await record(d, { ok: false, retry: true, error: err.name === 'TimeoutError' ? 'timed out' : err.message });}let answer = null;if (res.status !== 204) { try { answer = await res.json(); } catch { /* no JSON */ } }if (res.ok) {-- if (d.action === 'create') return created(d, res.status, answer, { type, localId, threadTarget, mapping: m });-- if (d.action === 'delete') db.prepare('UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(mapped.id);-- else db.prepare('UPDATE relay_message_map SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(mapped.id);-- return record(d, { ok: true, status: res.status });++ if (d.action === 'create') return await created(d, res.status, answer, { type, localId, threadTarget, mapping: m });++ if (d.action === 'delete') await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);++ else await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(mapped.id);++ return await record(d, { ok: true, status: res.status });}// The message itself is gone on Discord (someone deleted it there): a delete is done, an edit has nothing to edit.if (res.status === 404 && d.action !== 'create' && answer && Number(answer.code) === UNKNOWN_MESSAGE) {-- db.prepare('UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(mapped.id);-- return d.action === 'delete' ? record(d, { ok: true, status: 404, note: 'the message was already gone on Discord' }) : settle(d, 'skipped', 'the message is gone on Discord');++ await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);++ return d.action === 'delete' ? await record(d, { ok: true, status: 404, note: 'the message was already gone on Discord' }) : await settle(d, 'skipped', 'the message is gone on Discord');}const detail = answer && (answer.message || answer.error) ? String(answer.message || answer.error) : '';let retryAfter = Number(res.headers.get('retry-after')) || 0;if (answer && answer.retry_after) retryAfter = Math.max(retryAfter, Number(answer.retry_after) || 0);const retry = res.status === 429 || res.status === 408 || res.status >= 500;-- const outcome = record(d, { ok: false, retry, retryAfter, status: res.status, error: `Discord answered ${res.status}${detail ? `: ${detail}` : ''}` });++ const outcome = await record(d, { ok: false, retry, retryAfter, status: res.status, error: `Discord answered ${res.status}${detail ? `: ${detail}` : ''}` });return res.status === 429 ? { outcome, rateLimited: true } : outcome;}/** A create was answered: map the message (both ways unique) and learn the mapping's channel. */-- function created(d, status, msg, { type, localId, threadTarget, mapping }) {++ async function created(d, status, msg, { type, localId, threadTarget, mapping }) {const id = msg && SNOWFLAKE.test(String(msg.id || '')) ? String(msg.id) : null;const channel = msg && SNOWFLAKE.test(String(msg.channel_id || '')) ? String(msg.channel_id) : null;-- if (!id || !channel) return record(d, { ok: true, status, note: 'Discord did not answer with the message, so edits and deletes cannot follow it' });-- return db.transaction(() => {-- db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_thread_id, external_message_id, external_webhook_id)++ if (!id || !channel) return await record(d, { ok: true, status, note: 'Discord did not answer with the message, so edits and deletes cannot follow it' });++ return await db.tx(async () => {++ await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_thread_id, external_message_id, external_webhook_id)VALUES ('discord', ?, 'out', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(mapping.id, type, localId, d.thread_id, channel, threadTarget ? channel : null, id, msg.webhook_id ? String(msg.webhook_id) : null);-- if (!threadTarget) db.prepare('UPDATE relay_mappings SET discord_channel_id = ? WHERE id = ? AND discord_channel_id IS NULL').run(channel, mapping.id);-- return record(d, { ok: true, status });-- })();++ if (!threadTarget) await db.prepare('UPDATE relay_mappings SET discord_channel_id = ? WHERE id = ? AND discord_channel_id IS NULL').run(channel, mapping.id);++ return await record(d, { ok: true, status });++ });}/**@@@ -374,7 +383,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glconst summary = { delivered: 0, retry: 0, failed: 0, skipped: 0 };if (!enabled) return summary;// Deletes go out even for a disabled mapping: what left the site leaves Discord too.-- const due = db.prepare(`SELECT d.* FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id++ const due = await db.prepare(`SELECT d.* FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_idWHERE d.status = 'pending' AND (m.enabled = 1 OR d.action = 'delete') AND d.next_attempt_at <= ?ORDER BY d.id ASC LIMIT 50`).all(sqlTime(now()));const paused = new Set();@@@ -409,6 +418,8 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glstopped = true;if (timer) clearInterval(timer);timer = null;++ if (kickTimer) clearTimeout(kickTimer);++ kickTimer = null;const waits = [draining || Promise.resolve()];if (parts.worker) waits.push(parts.worker.stop());if (parts.gateway) waits.push(parts.gateway.stop());@@@ -423,7 +434,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl};}const mappingQuery = 'SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id';-- function listMappings() { return db.prepare(`${mappingQuery} ORDER BY m.id`).all().map(shapeMapping); }++ async function listMappings() { return (await db.prepare(`${mappingQuery} ORDER BY m.id`).all()).map(shapeMapping); }const snowflakeOrNull = (v, name) => {if (v === undefined) return undefined;if (v === null || v === '') return null;@@@ -431,17 +442,17 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glreturn String(v);};/** { space_id, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? } → mapping (made, or updated) */-- function addMapping({ space_id, webhook_url_ref, enabled: on = true, discord_channel_id, discord_thread_id, inbound }) {++ async function addMapping({ space_id, webhook_url_ref, enabled: on = true, discord_channel_id, discord_thread_id, inbound }) {if (!refAllowed(webhook_url_ref)) throw new Error(`${webhook_url_ref} is not an allowed webhook variable`);const channel = snowflakeOrNull(discord_channel_id, 'discord_channel_id');const thread = snowflakeOrNull(discord_thread_id, 'discord_thread_id');-- db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled, discord_channel_id, discord_thread_id, inbound) VALUES (?, 'out', ?, ?, ?, ?, ?)++ await db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled, discord_channel_id, discord_thread_id, inbound) VALUES (?, 'out', ?, ?, ?, ?, ?)ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0, channel || null, thread || null, inbound ? 1 : 0);-- const row = db.prepare(`${mappingQuery} WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?`).get(space_id, webhook_url_ref);-- return updateMapping(row.id, { discord_channel_id: channel, discord_thread_id: thread, inbound });++ const row = await db.prepare(`${mappingQuery} WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?`).get(space_id, webhook_url_ref);++ return await updateMapping(row.id, { discord_channel_id: channel, discord_thread_id: thread, inbound });}/** { enabled?, inbound?, discord_channel_id?, discord_thread_id? } → mapping, or null when there is no such mapping */-- function updateMapping(id, fields = {}) {++ async function updateMapping(id, fields = {}) {const sets = [];const params = [];if (fields.enabled !== undefined) { sets.push('enabled = ?'); params.push(fields.enabled ? 1 : 0); }@@@ -450,16 +461,16 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glconst v = snowflakeOrNull(fields[k], k);if (v !== undefined) { sets.push(`${k} = ?`); params.push(v); }}-- if (sets.length && !db.prepare(`UPDATE relay_mappings SET ${sets.join(', ')} WHERE id = ?`).run(...params, id).changes) return null;-- const row = db.prepare(`${mappingQuery} WHERE m.id = ?`).get(id);++ if (sets.length && !(await db.prepare(`UPDATE relay_mappings SET ${sets.join(', ')} WHERE id = ?`).run(...params, id)).changes) return null;++ const row = await db.prepare(`${mappingQuery} WHERE m.id = ?`).get(id);return row ? shapeMapping(row) : null;}-- function setMappingEnabled(id, on) { return updateMapping(id, { enabled: on }); }++ async function setMappingEnabled(id, on) { return await updateMapping(id, { enabled: on }); }-- function listDeliveries({ status = null, action = null, limit = 50 } = {}) {-- return db.prepare(`SELECT d.*, m.webhook_url_ref, t.title AS thread_title, t.slug AS thread_slug, s.slug AS space_slug++ async function listDeliveries({ status = null, action = null, limit = 50 } = {}) {++ return (await db.prepare(`SELECT d.*, m.webhook_url_ref, t.title AS thread_title, t.slug AS thread_slug, s.slug AS space_slugFROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id JOIN threads t ON t.id = d.thread_id JOIN spaces s ON s.id = t.space_id-- WHERE (? IS NULL OR d.status = ?) AND (? IS NULL OR d.action = ?) ORDER BY d.id DESC LIMIT ?`).all(status, status, action, action, limit)++ WHERE (?::text IS NULL OR d.status = ?) AND (?::text IS NULL OR d.action = ?) ORDER BY d.id DESC LIMIT ?`).all(status, status, action, action, limit)).map((d) => ({id: d.id, dedupe_key: d.dedupe_key, action: d.action, source: d.source, event_id: d.event_id || null,status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,@@@ -470,30 +481,30 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl}));}/** Put a delivery that was not delivered back in the queue now, with a fresh attempt budget. */-- function retry(id) {-- const n = db.prepare("UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status <> 'delivered'").run(sqlTime(now()), id).changes;++ async function retry(id) {++ const n = (await db.prepare("UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = ov_now() WHERE id = ? AND status <> 'delivered'").run(sqlTime(now()), id)).changes;if (n) kick();return n;}/** Staff give up on a waiting or dead delivery: 'dropped', kept for the record. */-- function drop(id) {-- return db.prepare("UPDATE relay_deliveries SET status = 'dropped', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('pending', 'failed')").run(id).changes;++ async function drop(id) {++ return (await db.prepare("UPDATE relay_deliveries SET status = 'dropped', updated_at = ov_now() WHERE id = ? AND status IN ('pending', 'failed')").run(id)).changes;}/** What staff (and /api/ready) see: the queue by status, and the Events worker's and the gateway's state. */-- function status() {++ async function status() {const deliveries = Object.fromEntries(DELIVERY_STATUSES.map((s) => [s, 0]));-- for (const r of db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) deliveries[r.status] = r.n;++ for (const r of await db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) deliveries[r.status] = r.n;const off = (reason) => ({ enabled: false, reason });return {enabled,creates_from: createsFromEvents ? 'events' : 'forum',deliveries,dead_letters: deliveries.failed,-- messages_mapped: db.prepare('SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL').get().n,-- events_worker: parts.worker ? parts.worker.status() : off(enabled ? 'not configured' : 'the relay is off'),++ messages_mapped: (await db.prepare('SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL').get()).n,++ events_worker: parts.worker ? await parts.worker.status() : off(enabled ? 'not configured' : 'the relay is off'),inbound: parts.gateway ? { ...parts.gateway.status(), ...(parts.inbound ? { handled: parts.inbound.stats() } : {}) } : off(enabled ? 'DISCORD_RELAY_INBOUND is off' : 'the relay is off'),-- inbound_failures: db.prepare('SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL').get().n,++ inbound_failures: (await db.prepare('SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL').get()).n,};}@@@ -51,28 +51,28 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, clet stopped = false;const iso = () => new Date(now()).toISOString();-- const readCursor = () => db.prepare('SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?').get(CURSOR) || null;-- function writeCursor(cursor, latest) {-- db.prepare(`INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)-- ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = CURRENT_TIMESTAMP`).run(CURSOR, cursor, latest == null ? null : latest);++ const readCursor = async () => await db.prepare('SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?').get(CURSOR) || null;++ async function writeCursor(cursor, latest) {++ await db.prepare(`INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, ov_now())++ ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = ov_now()`).run(CURSOR, cursor, latest == null ? null : latest);}/** One event → deliveries queued (0 when it is not for the relay). */-- function handle(ev) {++ async function handle(ev) {stats.seen++;if (!ev || ev.source !== 'community' || !ev.payload || ev.payload.redacted) return 0;const p = ev.payload;if (p.visibility && p.visibility !== 'public') return 0;if (ev.event_type === 'community.thread.created') {-- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(Number(p.thread_id));-- const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;-- return relay.enqueueThread(thread, space, { source: 'events', eventId: ev.event_id || null });++ const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(Number(p.thread_id));++ const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;++ return await relay.enqueueThread(thread, space, { source: 'events', eventId: ev.event_id || null });}if (ev.event_type === 'community.post.created') {-- const post = forumStore.getPost(db, Number(p.post_id));-- const thread = post ? db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id) : null;-- const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;-- return relay.enqueuePost(post, thread, space, { source: 'events', eventId: ev.event_id || null });++ const post = await forumStore.getPost(db, Number(p.post_id));++ const thread = post ? await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id) : null;++ const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;++ return await relay.enqueuePost(post, thread, space, { source: 'events', eventId: ev.event_id || null });}return 0;}@@@ -84,11 +84,11 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, crunning = (async () => {let queuedNow = 0;try {-- let cur = readCursor();++ let cur = await readCursor();if (!cur) {const head = await client.pull({ topic: TOPICS, afterSeq: 0, limit: 1 });const latest = Number(head.latest_seq) || 0;-- writeCursor(latest, latest);++ await writeCursor(latest, latest);stats.started_at_seq = latest;console.log(`[Relay] Events worker starts at seq ${latest} (earlier threads are not announced)`);cur = { cursor: latest, latest_seq: latest };@@@ -104,12 +104,12 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, cconst next = Number(page.next_after_seq);const latest = Number(page.latest_seq);const moved = Number.isFinite(next) && next > cursor ? next : cursor;-- queuedNow += db.transaction(() => {++ queuedNow += await db.tx(async () => {let n = 0;-- for (const item of page.events || []) n += handle(item && item.event);-- writeCursor(moved, Number.isFinite(latest) ? latest : null);++ for (const item of page.events || []) n += await handle(item && item.event);++ await writeCursor(moved, Number.isFinite(latest) ? latest : null);return n;-- })();++ });stats.pages++;if (moved === cursor || !Number.isFinite(latest) || moved >= latest) break;cursor = moved;@@@ -146,9 +146,9 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, creturn running ? running.then(() => undefined) : Promise.resolve();}-- function status() {++ async function status() {if (!on) return { enabled: false, reason };-- const cur = readCursor();++ const cur = await readCursor();return {enabled: true, running: !!timer, topics: TOPICS,cursor: cur ? cur.cursor : null, latest_seq: cur ? cur.latest_seq : null,@@@ -74,123 +74,123 @@ function createDiscordInbound({ db, perMinute = 6, maxChars = 4000, now = () =>return true;}-- const mapByMessage = (id) => db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?").get(String(id)) || null;-- const mappingById = (id) => db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(id) || null;++ const mapByMessage = async (id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?").get(String(id)) || null;++ const mappingById = async (id) => await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(id) || null;function ignore(reason) { counts.ignored++; return { status: 'ignored', reason }; }-- function failure(event, d, mappingId, error) {-- db.prepare(`INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error)++ async function failure(event, d, mappingId, error) {++ await db.prepare(`INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error)VALUES (?, ?, ?, ?, ?, ?)`).run(mappingId || null, event, d.channel_id ? String(d.channel_id) : null, d.id ? String(d.id) : null, d.author && d.author.id ? String(d.author.id) : null, String(error).slice(0, 300));-- db.prepare('DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?').run(KEEP_FAILURES);++ await db.prepare('DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?').run(KEEP_FAILURES);counts.failed++;return { status: 'failed', error };}/** Is the local thread still somewhere a reply from Discord may land? → null or why not */-- function closed(thread) {++ async function closed(thread) {if (!thread || thread.deleted_at) return 'the thread was deleted';-- const space = forumStore.getSpaceById(db, thread.space_id);++ const space = await forumStore.getSpaceById(db, thread.space_id);if (!space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is not public';if (thread.locked) return 'the thread is locked';return null;}-- function create(d, botUserId) {++ async function create(d, botUserId) {if (d.webhook_id) return ignore('a webhook message (the relay\'s own included)');const author = d.author || {};if (!author.id || author.bot || author.system || (botUserId && String(author.id) === String(botUserId))) return ignore('a bot or system message');if (d.type !== undefined && !MESSAGE_TYPES.has(Number(d.type))) return ignore('not a plain message or a reply');if (!SNOWFLAKE.test(String(d.id || '')) || !SNOWFLAKE.test(String(d.channel_id || ''))) return ignore('malformed ids');-- if (mapByMessage(d.id)) return ignore('already relayed');++ if (await mapByMessage(d.id)) return ignore('already relayed');const channel = String(d.channel_id);// A Discord thread started from a mapped message, else a reply to one in the same channel.-- let anchor = mapByMessage(channel);++ let anchor = await mapByMessage(channel);// (message_reference type 1 is a forward, not a reply: never taken in.)if (!anchor && d.message_reference && d.message_reference.message_id && !Number(d.message_reference.type || 0)) {-- const ref = mapByMessage(String(d.message_reference.message_id));++ const ref = await mapByMessage(String(d.message_reference.message_id));if (ref && ref.external_channel_id === channel) anchor = ref;}if (!anchor) return ignore('not a reply to a relayed message');-- const mapping = mappingById(anchor.mapping_id);++ const mapping = await mappingById(anchor.mapping_id);if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');const mapped = [mapping.discord_channel_id, mapping.discord_thread_id].filter(Boolean);const inMapped = mapped.includes(channel) || (channel === anchor.external_message_id && mapped.includes(anchor.external_channel_id));if (!inMapped) return ignore('not a mapped channel');-- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(anchor.thread_id);-- const why = closed(thread);-- if (why) return failure('MESSAGE_CREATE', d, mapping.id, why);-- if (!allow(`a:${mapping.id}:${author.id}`, perMinute) || !allow(`m:${mapping.id}`, perMinute * 10)) return failure('MESSAGE_CREATE', d, mapping.id, 'rate limited');++ const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(anchor.thread_id);++ const why = await closed(thread);++ if (why) return await failure('MESSAGE_CREATE', d, mapping.id, why);++ if (!allow(`a:${mapping.id}:${author.id}`, perMinute) || !allow(`m:${mapping.id}`, perMinute * 10)) return await failure('MESSAGE_CREATE', d, mapping.id, 'rate limited');const body = toMarkdown(d, maxChars);-- if (!body) return failure('MESSAGE_CREATE', d, mapping.id, 'the message has no text: is the MESSAGE CONTENT intent on for the bot?');++ if (!body) return await failure('MESSAGE_CREATE', d, mapping.id, 'the message has no text: is the MESSAGE CONTENT intent on for the bot?');const name = cleanName((d.member && d.member.nick) || author.global_name || author.username) || 'Discord';try {-- const post = db.transaction(() => {-- const p = forumStore.addPost(db, { thread_id: thread.id, author_subject: null, origin: 'discord', body_markdown: body, relay_author: name });-- db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)++ const post = await db.tx(async () => {++ const p = await forumStore.addPost(db, { thread_id: thread.id, author_subject: null, origin: 'discord', body_markdown: body, relay_author: name });++ await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)VALUES ('discord', ?, 'in', 'post', ?, ?, ?, ?)`).run(mapping.id, p.id, thread.id, channel, String(d.id));return p;-- })();++ });counts.applied++;return { status: 'applied', post_id: post.id };} catch (err) {-- if (/UNIQUE/.test(err.message)) return ignore('already relayed');-- return failure('MESSAGE_CREATE', d, mapping.id, `could not save the post: ${err.message}`);++ if (err.code === '23505') return ignore('already relayed');++ return await failure('MESSAGE_CREATE', d, mapping.id, `could not save the post: ${err.message}`);}}-- function update(d) {++ async function update(d) {if (!d.id) return ignore('malformed ids');-- const row = mapByMessage(d.id);++ const row = await mapByMessage(d.id);if (!row) return ignore('not a relayed message');if (row.direction !== 'in') return ignore('the relay\'s own message');if (typeof d.content !== 'string') return ignore('no text change');-- const mapping = mappingById(row.mapping_id);++ const mapping = await mappingById(row.mapping_id);if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');-- const post = forumStore.getPost(db, row.local_id);-- if (!post || post.deleted_at) return failure('MESSAGE_UPDATE', d, mapping.id, 'the post was deleted');-- const why = closed(db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id));-- if (why) return failure('MESSAGE_UPDATE', d, mapping.id, why);++ const post = await forumStore.getPost(db, row.local_id);++ if (!post || post.deleted_at) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the post was deleted');++ const why = await closed(await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id));++ if (why) return await failure('MESSAGE_UPDATE', d, mapping.id, why);const body = toMarkdown(d, maxChars);-- if (!body) return failure('MESSAGE_UPDATE', d, mapping.id, 'the edit has no text');-- forumStore.editPost(db, post.id, body, 'discord');-- db.prepare('UPDATE relay_message_map SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);++ if (!body) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the edit has no text');++ await forumStore.editPost(db, post.id, body, 'discord');++ await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(row.id);counts.applied++;return { status: 'applied', post_id: post.id };}/** A delete on Discord. Applied whatever the mapping's switches: taking something down never waits. */-- function remove(id) {-- const row = mapByMessage(id);++ async function remove(id) {++ const row = await mapByMessage(id);if (!row) return ignore('not a relayed message');-- db.transaction(() => {-- db.prepare('UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND external_deleted_at IS NULL').run(row.id);-- if (row.direction === 'in') forumStore.softDeletePost(db, row.local_id);-- })();++ await db.tx(async () => {++ await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ? AND external_deleted_at IS NULL').run(row.id);++ if (row.direction === 'in') await forumStore.softDeletePost(db, row.local_id);++ });counts.applied++;return { status: 'applied', post_id: row.direction === 'in' ? row.local_id : null, note: row.direction === 'out' ? 'the relay\'s message was deleted on Discord; the thread stays' : undefined };}/** One gateway dispatch (type, data, { botUserId }) → result (an array for a bulk delete). */-- function handle(type, d, { botUserId = null } = {}) {++ async function handle(type, d, { botUserId = null } = {}) {if (!d || typeof d !== 'object') return ignore('no data');-- if (type === 'MESSAGE_CREATE') return create(d, botUserId);-- if (type === 'MESSAGE_UPDATE') return update(d);-- if (type === 'MESSAGE_DELETE') return remove(String(d.id || ''));-- if (type === 'MESSAGE_DELETE_BULK') return (Array.isArray(d.ids) ? d.ids : []).map((id) => remove(String(id)));++ if (type === 'MESSAGE_CREATE') return await create(d, botUserId);++ if (type === 'MESSAGE_UPDATE') return await update(d);++ if (type === 'MESSAGE_DELETE') return await remove(String(d.id || ''));++ if (type === 'MESSAGE_DELETE_BULK') return (await Promise.all((Array.isArray(d.ids) ? d.ids : []).map(async (id) => await remove(String(id)))));return ignore('not a message event');}// ── staff ────────────────────────────────────────────────-- function listFailures({ all = false, limit = 50 } = {}) {-- return db.prepare(`SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id-- WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?`).all(all ? 1 : 0, limit)++ async function listFailures({ all = false, limit = 50 } = {}) {++ return (await db.prepare(`SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id++ WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?`).all(all ? 1 : 0, limit)).map((f) => ({id: f.id, event: f.event, error: f.error, mapping: f.mapping_id ? { id: f.mapping_id, space: f.space_slug || null } : null,discord: { channel_id: f.external_channel_id, message_id: f.external_message_id, author_id: f.external_author_id },dismissed: !!f.dismissed_at, created_at: f.created_at ? new Date(`${String(f.created_at).replace(' ', 'T')}Z`).toISOString() : null,}));}-- function dismiss(id) {-- return db.prepare('UPDATE relay_inbound_failures SET dismissed_at = CURRENT_TIMESTAMP WHERE id = ? AND dismissed_at IS NULL').run(id).changes;++ async function dismiss(id) {++ return (await db.prepare('UPDATE relay_inbound_failures SET dismissed_at = ov_now() WHERE id = ? AND dismissed_at IS NULL').run(id)).changes;}return { handle, listFailures, dismiss, stats: () => ({ ...counts }) };@@@ -48,25 +48,17 @@ function createSearchDocuments({ db }) {let lastScan = null;let timers = [];-- db.exec(`CREATE TABLE IF NOT EXISTS search_doc_pushes (-- type TEXT NOT NULL,-- id TEXT NOT NULL,-- hash TEXT NOT NULL,-- revision INTEGER NOT NULL,-- deleted INTEGER NOT NULL DEFAULT 0,-- pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (type, id)-- )`);++ // search_doc_pushes is in migrations/0001_initial.sql.const enabled = () => !!events.status().enabled;/** The document for one thread, `{ deleted: true }` when Search must not hold it. */-- function threadDocument(threadId) {-- const t = db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name, s.visibility AS space_visibility,++ async function threadDocument(threadId) {++ const t = await db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name, s.visibility AS space_visibility,s.members_only_owner AS space_members_only, c.slug AS category_slugFROM threads t JOIN spaces s ON s.id = t.space_id LEFT JOIN categories c ON c.id = t.category_idWHERE t.id = ?`).get(threadId);if (!t || t.deleted_at || t.space_visibility !== 'public' || t.space_members_only || t.members_only_owner) return { deleted: true };-- const posts = db.prepare('SELECT body_markdown, is_opening FROM posts WHERE thread_id = ? AND deleted_at IS NULL ORDER BY is_opening DESC, id ASC').all(t.id);++ const posts = await db.prepare('SELECT body_markdown, is_opening FROM posts WHERE thread_id = ? AND deleted_at IS NULL ORDER BY is_opening DESC, id ASC').all(t.id);const opening = posts.find((p) => p.is_opening);const facets = { space: t.space_slug, kind: t.kind || 'discussion', replies: Number(t.reply_count) || 0, score: Number(t.score) || 0 };if (t.status) facets.status = String(t.status).slice(0, 200);@@@ -111,55 +103,55 @@ function createSearchDocuments({ db }) {}/** Send one document or tombstone when it changed. → 'sent' | 'tombstone' | 'unchanged' | 'skipped' */-- function send(type, id, doc) {++ async function send(type, id, doc) {if (!enabled()) return 'skipped';-- const prev = db.prepare('SELECT hash, revision, deleted FROM search_doc_pushes WHERE type = ? AND id = ?').get(type, id);++ const prev = await db.prepare('SELECT hash, revision, deleted FROM search_doc_pushes WHERE type = ? AND id = ?').get(type, id);if (doc.deleted && (!prev || prev.deleted)) { stats.unchanged++; return 'unchanged'; } // never sent, or already goneconst hash = doc.deleted ? 'deleted' : hashOf(doc);if (prev && prev.hash === hash) { stats.unchanged++; return 'unchanged'; }const revision = (prev ? prev.revision : 0) + 1;-- db.transaction(() => {-- if (doc.deleted) events.record('community.index_document.deleted', { type, id, revision }, { type, id, revision });-- else events.record('community.index_document.upserted', { type, id, revision }, { ...doc, revision });-- db.prepare(`INSERT INTO search_doc_pushes (type, id, hash, revision, deleted, pushed_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)++ await db.tx(async () => {++ if (doc.deleted) await events.record('community.index_document.deleted', { type, id, revision }, { type, id, revision });++ else await events.record('community.index_document.upserted', { type, id, revision }, { ...doc, revision });++ await db.prepare(`INSERT INTO search_doc_pushes (type, id, hash, revision, deleted, pushed_at) VALUES (?, ?, ?, ?, ?, ov_now())ON CONFLICT(type, id) DO UPDATE SET hash = excluded.hash, revision = excluded.revision, deleted = excluded.deleted, pushed_at = excluded.pushed_at`).run(type, id, hash, revision, doc.deleted ? 1 : 0);-- })();++ });if (doc.deleted) { stats.tombstones++; return 'tombstone'; }stats.sent++;return 'sent';}-- const publishThread = (threadId) => send('thread', String(threadId), threadDocument(threadId));-- function publishPaste(row) {++ const publishThread = async (threadId) => await send('thread', String(threadId), await threadDocument(threadId));++ async function publishPaste(row) {if (!row || !SLUG_RE.test(String(row.slug || ''))) return 'skipped';-- return send('paste', pasteDocId(row), pasteDocument(row));++ return await send('paste', pasteDocId(row), pasteDocument(row));}-- const guard = (fn) => { try { fn(); } catch (err) { stats.lastError = err.message; } };++ const guard = async (fn) => { try { await fn(); } catch (err) { stats.lastError = err.message; } };-- function scan({ now = Date.now() } = {}) {++ async function scan({ now = Date.now() } = {}) {if (!enabled()) return 0;const from = lastScan || sqlTime(now - 10 * 60 * 1000);const to = sqlTime(now);-- const threads = db.prepare('SELECT id FROM threads').all();-- for (const t of threads) guard(() => publishThread(t.id));-- const pastes = db.prepare('SELECT * FROM pastes WHERE (updated_at >= @from AND updated_at < @to) OR (deleted_at >= @from AND deleted_at < @to) OR (created_at >= @from AND created_at < @to)').all({ from, to });-- for (const p of pastes) guard(() => publishPaste(p));++ const threads = await db.prepare('SELECT id FROM threads').all();++ for (const t of threads) await guard(async () => await publishThread(t.id));++ const pastes = await db.prepare('SELECT * FROM pastes WHERE (updated_at >= @from AND updated_at < @to) OR (deleted_at >= @from AND deleted_at < @to) OR (created_at >= @from AND created_at < @to)').all({ from, to });++ for (const p of pastes) await guard(async () => await publishPaste(p));lastScan = to;stats.lastScanAt = new Date(now).toISOString();return threads.length + pastes.length;}-- function refresh() {++ async function refresh() {if (!enabled()) return 0;-- const rows = db.prepare('SELECT * FROM pastes').all();++ const rows = await db.prepare('SELECT * FROM pastes').all();const seen = new Set();-- for (const p of rows) { seen.add(pasteDocId(p)); guard(() => publishPaste(p)); }-- for (const r of db.prepare("SELECT id FROM search_doc_pushes WHERE type = 'paste' AND deleted = 0").all()) {++ for (const p of rows) { seen.add(pasteDocId(p)); await guard(async () => await publishPaste(p)); }++ for (const r of await db.prepare("SELECT id FROM search_doc_pushes WHERE type = 'paste' AND deleted = 0").all()) {if (seen.has(r.id)) continue;// An id Search could never accept (sent before paste_<id> existed): nothing to remove there.-- if (!DOC_ID_RE.test(r.id)) db.prepare("DELETE FROM search_doc_pushes WHERE type = 'paste' AND id = ?").run(r.id);-- else guard(() => send('paste', r.id, { deleted: true }));++ if (!DOC_ID_RE.test(r.id)) await db.prepare("DELETE FROM search_doc_pushes WHERE type = 'paste' AND id = ?").run(r.id);++ else await guard(async () => await send('paste', r.id, { deleted: true }));}return rows.length;}@@@ -167,12 +159,12 @@ function createSearchDocuments({ db }) {function start() {if (timers.length || !enabled() || process.env.COMMUNITY_SEARCH_DOCUMENTS === 'off') return false;const every = (ms, fn, first) => {-- const t0 = setTimeout(() => guard(fn), first); if (t0.unref) t0.unref();-- const t = setInterval(() => guard(fn), ms); if (t.unref) t.unref();++ const t0 = setTimeout(async () => await guard(fn), first); if (t0.unref) t0.unref();++ const t = setInterval(async () => await guard(fn), ms); if (t.unref) t.unref();timers.push(t0, t);};-- every(SCAN_MS, () => scan(), 45 * 1000);-- every(REFRESH_MS, () => refresh(), 2 * 60 * 1000);++ every(SCAN_MS, async () => await scan(), 45 * 1000);++ every(REFRESH_MS, async () => await refresh(), 2 * 60 * 1000);return true;}function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }@@@ -176,8 +176,8 @@ async function buildSitemap() {if (_forum) {add('/s', null, 'hourly', '0.8');add('/pulse', null, 'hourly', '0.5');-- for (const s of _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');-- for (const t of _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');++ for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');++ for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');}return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;}@@@ -3,12 +3,11 @@/*** Up/down votes on comments and forum threads — one row per (target, person), value 1 or -1.*-- * Race safety: a vote is a single UPSERT (or DELETE for 0) followed, in the same IMMEDIATE-- * transaction, by recomputing the target's score from the vote rows. The score is never-- * incremented or decremented, so interleaved votes from any number of connections or processes-- * cannot drift it: whatever order the writes land in, the stored score is the sum of the rows-- * that exist when the last transaction commits. IMMEDIATE takes SQLite's write lock up front-- * (instead of upgrading a read lock), so two writers queue on busy_timeout rather than failing.++ * Race safety: a vote is a single UPSERT (or DELETE for 0) followed, in the same transaction, by++ * recomputing the target's score from the vote rows. The score is never incremented or decremented,++ * so interleaved votes from any number of connections or processes cannot drift it. The transaction++ * first locks the target row (SELECT … FOR UPDATE, PostgreSQL's stand-in for SQLite's IMMEDIATE), so++ * votes on one target queue and each recount sees every vote committed before it.*/const TARGETS = {comment: { votes: 'comment_votes', key: 'comment_id', table: 'comments', counts: true },@@@ -16,35 +15,36 @@ const TARGETS = {};/** value 1 | -1 | 0 (0 removes the vote). → { score, upvotes, downvotes, my_vote } */--function applyVote(db, kind, targetId, subject, value) {++async function applyVote(db, kind, targetId, subject, value) {const t = TARGETS[kind];if (!t) throw new Error(`unknown vote target ${kind}`);if (![1, -1, 0].includes(value)) throw new Error('vote value must be 1, -1 or 0');-- return db.transaction(() => {++ return await db.tx(async () => {++ await db.prepare(`SELECT id FROM ${t.table} WHERE id = ? FOR UPDATE`).get(targetId);if (value === 0) {-- db.prepare(`DELETE FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`).run(targetId, subject);++ await db.prepare(`DELETE FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`).run(targetId, subject);} else {-- db.prepare(`INSERT INTO ${t.votes} (${t.key}, subject_id, value) VALUES (?, ?, ?)-- ON CONFLICT(${t.key}, subject_id) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`)++ await db.prepare(`INSERT INTO ${t.votes} (${t.key}, subject_id, value) VALUES (?, ?, ?)++ ON CONFLICT(${t.key}, subject_id) DO UPDATE SET value = excluded.value, updated_at = ov_now()`).run(targetId, subject, value);}-- const agg = db.prepare(`SELECT COALESCE(SUM(value), 0) AS score,-- COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,-- COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS downvotes++ const agg = await db.prepare(`SELECT COALESCE(SUM(value), 0)::bigint AS score,++ COUNT(*) FILTER (WHERE value = 1) AS upvotes,++ COUNT(*) FILTER (WHERE value = -1) AS downvotesFROM ${t.votes} WHERE ${t.key} = ?`).get(targetId);-- if (t.counts) db.prepare(`UPDATE ${t.table} SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?`).run(agg.score, agg.upvotes, agg.downvotes, targetId);-- else db.prepare(`UPDATE ${t.table} SET score = ? WHERE id = ?`).run(agg.score, targetId);++ if (t.counts) await db.prepare(`UPDATE ${t.table} SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?`).run(agg.score, agg.upvotes, agg.downvotes, targetId);++ else await db.prepare(`UPDATE ${t.table} SET score = ? WHERE id = ?`).run(agg.score, targetId);return { score: agg.score, upvotes: agg.upvotes, downvotes: agg.downvotes, my_vote: value };-- }).immediate();++ });}/** The person's current votes on some targets. → Map id → 1|-1 */--function myVotes(db, kind, targetIds, subject) {++async function myVotes(db, kind, targetIds, subject) {const t = TARGETS[kind];const out = new Map();if (!subject || !targetIds.length) return out;-- const stmt = db.prepare(`SELECT value FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`);-- for (const id of new Set(targetIds)) { const r = stmt.get(id, subject); if (r) out.set(id, r.value); }++ const rows = await db.prepare(`SELECT ${t.key} AS id, value FROM ${t.votes} WHERE ${t.key} = ANY(?::bigint[]) AND subject_id = ?`).all([...new Set(targetIds)], subject);++ for (const r of rows) out.set(r.id, r.value);return out;}@@@ -12,36 +12,36 @@ const http = require('http');const express = require('express');const { signDeliveryHeaders } = require('openvibe-sdk/events');const { validate } = require('openvibe-contracts');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');--const SECRET = `whsec_${'ab'.repeat(32)}`;--const db = openDb(':memory:');--const sent = [];--let failNext = false;--const accountSend = async (p, body) => { if (failNext) { failNext = false; return { ok: false, status: 503 }; } sent.push({ path: p, body }); return { ok: true, status: 200 }; };--const app = express();--app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], accountSend, log: { error() {}, log() {}, warn() {} } }).router);--const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1', OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2', OTTO = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';----db.prepare("INSERT INTO pastes (id, slug, owner_subject, title, content, likes) VALUES (1, 'p1', ?, 'mine, answered', 'secret words', 1), (2, 'p2', ?, 'mine, alone', 'more', 0), (3, 'p3', ?, 'otto''s', 'x', 1), (4, 'p4', ?, 'old account', 'y', 0)").run(DANA, DANA, OTTO, OLD);--db.prepare("INSERT INTO paste_comments (id, paste_id, author_subject, message, parent_id) VALUES (1, 1, ?, 'nice', NULL), (2, 3, ?, 'hi otto', NULL), (3, 3, ?, 'hi dana', 2), (4, 3, ?, 'bye', NULL)").run(OTTO, DANA, OTTO, DANA);--db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);--db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) VALUES (1, 'live', 'vod', '9')").run();--db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);--db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);--db.prepare("INSERT INTO spaces (id, slug, name, created_by) VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);--db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);--db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject, is_opening) VALUES (1, 1, 'opening', ?, 1), (2, 1, 'otto answers', ?, 0), (3, 2, 'quiet opening', ?, 1)").run(DANA, OTTO, DANA);--db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (2, ?, 1)').run(OTTO);--db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (2, ?, 'fire')").run(DANA);--db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 4, '2026-09-27')").run(DANA, OTTO);--require('../server/identity/blocks').ensureSchema(db);--db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(DANA, OTTO, OTTO, DANA);----const evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),-- subject: { type: 'user', id: payload.subject }, actor: { type: 'user', id: payload.subject }, payload });++let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;(async () => {++ SECRET = `whsec_${'ab'.repeat(32)}`;++ db = await testDb();++ sent = [];++ failNext = false;++ accountSend = async (p, body) => { if (failNext) { failNext = false; return { ok: false, status: 503 }; } sent.push({ path: p, body }); return { ok: true, status: 200 }; };++ app = express();++ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], accountSend, log: { error() {}, log() {}, warn() {} } }).router);++ DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';++ OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';++ OTTO = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';++ await db.prepare("INSERT INTO pastes (id, slug, owner_subject, title, content, likes) OVERRIDING SYSTEM VALUE VALUES (1, 'p1', ?, 'mine, answered', 'secret words', 1), (2, 'p2', ?, 'mine, alone', 'more', 0), (3, 'p3', ?, 'otto''s', 'x', 1), (4, 'p4', ?, 'old account', 'y', 0)").run(DANA, DANA, OTTO, OLD);++ await db.prepare("INSERT INTO paste_comments (id, paste_id, author_subject, message, parent_id) OVERRIDING SYSTEM VALUE VALUES (1, 1, ?, 'nice', NULL), (2, 3, ?, 'hi otto', NULL), (3, 3, ?, 'hi dana', 2), (4, 3, ?, 'bye', NULL)").run(OTTO, DANA, OTTO, DANA);++ await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);++ await db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'vod', '9')").run();++ await db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) OVERRIDING SYSTEM VALUE VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);++ await db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);++ await db.prepare("INSERT INTO spaces (id, slug, name, created_by) OVERRIDING SYSTEM VALUE VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);++ await db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) OVERRIDING SYSTEM VALUE VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);++ await db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject, is_opening) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'opening', ?, 1), (2, 1, 'otto answers', ?, 0), (3, 2, 'quiet opening', ?, 1)").run(DANA, OTTO, DANA);++ await db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (2, ?, 1)').run(OTTO);++ await db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (2, ?, 'fire')").run(DANA);++ await db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 4, '2026-09-27')").run(DANA, OTTO);++ await db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(DANA, OTTO, OTTO, DANA);++ evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),++ subject: { type: 'user', id: payload.subject }, actor: { type: 'user', id: payload.subject }, payload });assert.ok(TOPICS.includes('network.account.export_requested') && TOPICS.includes('network.account.deleted'), 'Community subscribes to both');const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });const post = (event) => {@@@ -70,20 +70,20 @@ const evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1failNext = true;r = await post(del);assert.strictEqual(r.status, 500, 'a failed confirmation is redelivered');-- const all = (sql) => db.prepare(sql).all();-- assert.deepStrictEqual(all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [++ const all = async (sql) => await db.prepare(sql).all();++ assert.deepStrictEqual(await all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [{ slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");-- assert.deepStrictEqual(all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [++ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');-- assert.deepStrictEqual(all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [++ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [{ id: 1, a: null, message: '[deleted]', score: 0, reply_count: 1 }, { id: 2, a: OTTO, message: 'reply', score: 0, reply_count: 0 }], 'votes gone and the score recomputed');-- assert.deepStrictEqual(all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');-- assert.deepStrictEqual(all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);-- assert.strictEqual(all('SELECT * FROM post_reactions').length + all('SELECT * FROM paste_likes').length, 0);-- assert.strictEqual(db.prepare('SELECT likes FROM pastes WHERE id = 3').get().likes, 0, 'the like count follows');-- assert.deepStrictEqual(all('SELECT subject_id AS s FROM game_progress'), [{ s: OTTO }]);-- assert.strictEqual(all('SELECT * FROM network_blocks').length, 0, 'blocks both ways');-- assert.strictEqual(db.prepare('SELECT created_by FROM spaces WHERE id = 901').get().created_by, null, 'the space stays without her');++ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');++ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);++ assert.strictEqual((await all('SELECT * FROM post_reactions')).length + (await all('SELECT * FROM paste_likes')).length, 0);++ assert.strictEqual((await db.prepare('SELECT likes FROM pastes WHERE id = 3').get()).likes, 0, 'the like count follows');++ assert.deepStrictEqual(await all('SELECT subject_id AS s FROM game_progress'), [{ s: OTTO }]);++ assert.strictEqual((await all('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');++ assert.strictEqual((await db.prepare('SELECT created_by FROM spaces WHERE id = 901').get()).created_by, null, 'the space stays without her');r = await post(del);assert.deepStrictEqual([r.status, r.json.outcome], [200, 'confirmed'], 'the retry confirms without erasing again');@@@ -80,7 +80,7 @@ const { actor, serviceItself } = require('../server/actor-limits');}const r = await post(20);assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);-- const stored = t.db.prepare("SELECT COUNT(*) AS n FROM comments c JOIN comment_threads th ON th.id = c.thread_id WHERE th.ref_service = 'live' AND th.ref_type = 'stream' AND th.ref_id = '42'").get().n;++ const stored = (await t.db.prepare("SELECT COUNT(*) AS n FROM comments c JOIN comment_threads th ON th.id = c.thread_id WHERE th.ref_service = 'live' AND th.ref_type = 'stream' AND th.ref_id = '42'").get()).n;assert.strictEqual(stored, 20, 'nothing stored');assert.strictEqual((await call(`/api/v1/comments/threads/${thread.access_id || thread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } })).status, 201, 'another person still comments');});@@@ -61,8 +61,8 @@ const SECRET = `whsec_${'cd'.repeat(32)}`;const bad = block(alex.subject_id, sam.subject_id, false, 9); bad.payload.blocked = 'sam';assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');const blocks = require('../server/identity/blocks');-- assert.ok(blocks.hasBlocked(t.db, alex.subject_id, sam.subject_id));-- assert.ok(!blocks.hasBlocked(t.db, sam.subject_id, alex.subject_id), 'one direction');++ assert.ok(await blocks.hasBlocked(t.db, alex.subject_id, sam.subject_id));++ assert.ok(!await blocks.hasBlocked(t.db, sam.subject_id, alex.subject_id), 'one direction');});await check('forum: no reply in a thread whose author blocked you (API problem and no-JS form); others reply', async () => {@@@ -100,18 +100,18 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');{ key: 'nostatus', title: 'No status', summary: 'y' },];const quiet = { warn() {} };-- assert.deepStrictEqual(syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });++ assert.deepStrictEqual(await syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });let th = (await call('/api/v1/spaces/roadmap/threads?category=features')).json().threads.find((x) => x.title === 'Calls in Chat');assert.strictEqual(th.author.display_name, 'OpenVibe'); assert.strictEqual(th.origin, 'system');-- assert.deepStrictEqual(syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });++ assert.deepStrictEqual(await syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });const next = [{ ...items[0], status: 'in_progress', title: 'Voice and video calls in Chat', summary: 'Calls move to Chat. Started.' }];-- assert.deepStrictEqual(syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });++ assert.deepStrictEqual(await syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });const page = (await call(`/api/v1/spaces/roadmap/threads/${th.slug}`)).json();assert.strictEqual(page.thread.title, 'Voice and video calls in Chat'); assert.strictEqual(page.thread.status, 'in_progress');assert.strictEqual(page.posts[0].body_markdown, 'Calls move to Chat. Started.'); assert.strictEqual(page.posts[0].revision, 2, 'the summary edit keeps the history');-- const file = syncFromFile(db, { log: quiet });++ const file = await syncFromFile(db, { log: quiet });assert.ok(file && file.created >= 10 && file.skipped === 0, JSON.stringify(file));-- assert.deepStrictEqual(syncFromFile(db, { log: quiet }).updated, 0, 'the shipped file syncs to a fixed point');++ assert.deepStrictEqual((await syncFromFile(db, { log: quiet })).updated, 0, 'the shipped file syncs to a fixed point');});await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {@@@ -76,8 +76,8 @@ function startStubChat() {const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });// Alex owns the Showcase space (its creator) and the rooms night-owls and secret; Sam owns sams-room; Boss (staff) owns staff-room.-- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);-- t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);++ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);++ await t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);chat.addRoom('night-owls', { name: 'Night Owls', owner: alex.subject_id });chat.addRoom('secret', { name: 'Secret', owner: sam.subject_id, visibility: 'private', members: [sam.subject_id] });chat.addRoom('stage', { name: 'Stage', kind: 'call', owner: sam.subject_id });@@@ -85,7 +85,7 @@ function startStubChat() {chat.addRoom('staff-room', { name: 'Staff Room', owner: bossSubject });const api = (method, path, jwt, body) => t.get(`/api/v1/spaces${path}`, { method, headers: { ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });const form = (jwt, path, fields, origin = 'https://openvibe.community') => t.get(path, { method: 'POST', cookies: [`ov_token=${jwt}`], headers: { 'content-type': 'application/x-www-form-urlencoded', origin }, body: new URLSearchParams(fields).toString() });-- const rows = () => t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();++ const rows = async () => await t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();await check('only the space\'s owner or staff attach, and only as themselves', async () => {const before = chat.calls.length;@@@ -98,7 +98,7 @@ function startStubChat() {r = await api('PUT', '/showcase/chat-room', alexJwt, { room: '../../admin' });assert.deepStrictEqual([r.status, r.json().code], [400, 'chat_room.invalid']);assert.strictEqual(chat.calls.length, before, 'Chat was not asked for any of these');-- assert.deepStrictEqual(rows(), []);++ assert.deepStrictEqual(await rows(), []);});await check('the owner attaches by link; Chat is asked with their own token; again is a no-op', async () => {@@@ -109,8 +109,8 @@ function startStubChat() {assert.deepStrictEqual([call.method, call.url, call.auth, call.body], ['POST', '/api/chat/rooms/night-owls/attachments', `Bearer ${alexJwt}`, { service: 'community', resource: 'showcase', title: 'Showcase' }]);r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });assert.deepStrictEqual([r.status, r.json().created], [200, false]);-- assert.strictEqual(rows().length, 1);-- assert.strictEqual(rows()[0].attached_by, alex.subject_id);++ assert.strictEqual((await rows()).length, 1);++ assert.strictEqual((await rows())[0].attached_by, alex.subject_id);assert.strictEqual(chat.attachments.size, 1);assert.strictEqual((await api('GET', '/showcase')).json().space.chat_room.slug, 'night-owls', 'the space says which room');});@@@ -131,7 +131,7 @@ function startStubChat() {const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);chat.state.down = false;-- assert.strictEqual(rows()[0].room_slug, 'night-owls');++ assert.strictEqual((await rows())[0].room_slug, 'night-owls');});await check('the space page links the room; its owner gets the forms; others only the link', async () => {@@@ -160,13 +160,13 @@ function startStubChat() {assert.deepStrictEqual([r.status, r.json().created, r.json().chat_room.slug], [201, true, 'staff-room']);assert.ok(chat.calls.some((c) => c.method === 'DELETE' && c.url === '/api/chat/rooms/night-owls/attachments/community/showcase'));assert.strictEqual(chat.attachments.has('night-owls|showcase'), true, 'Chat keeps the old side when the caller may not remove it');-- assert.strictEqual(rows().find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);++ assert.strictEqual((await rows()).find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);// The owner puts theirs back and detaches it without JavaScript: both sides go.r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });assert.strictEqual(r.status, 201);const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);-- assert.ok(!rows().some((x) => x.room_slug === 'night-owls'));++ assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');r = await api('DELETE', '/showcase/chat-room', alexJwt);assert.deepStrictEqual([r.status, r.json().detached], [200, false], 'detaching twice is fine');@@@ -60,7 +60,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual(b.json().created, false);const other = await resolve({ service: 'live', type: 'channel', id: '123' });assert.notStrictEqual(other.json().thread.id, vod.id);-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'stream' AND ref_id = '123'").get().c, 1);++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'stream' AND ref_id = '123'").get()).c, 1);});await check('resolve: browsers only for allowlisted types; services with comment.write for any; labels only from services', async () => {@@@ -109,7 +109,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual(c.anon_name, null);assert.strictEqual(c.origin, 'user');assert.strictEqual(c.can_delete, true);-- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, 1);++ assert.strictEqual((await t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id)).comment_count, 1);assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);@@@ -194,20 +194,20 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual((await vote(-1, { token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).json().score, 0);const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`, { cookie: alexJwt })).json();assert.strictEqual(page.comments.find((x) => x.id === c.id).my_vote, 1);-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(c.id).n, 2);++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(c.id)).n, 2);});await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });-- const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count;++ const before = (await t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id)).comment_count;assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });assert.strictEqual(ok.status, 200, ok.text);-- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, before - 1);-- assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id).message, '', 'scrubbed');++ assert.strictEqual((await t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id)).comment_count, before - 1);++ assert.strictEqual((await t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id)).message, '', 'scrubbed');const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();const tomb = page.comments.find((c) => c.id === top.id);assert.ok(tomb && tomb.deleted && tomb.message === null && tomb.author === null);@@@ -264,7 +264,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual(c.author.is_ai, true);assert.strictEqual(c.display_name, 'OpenVibe AI');assert.strictEqual(c.anon_name, null);-- assert.strictEqual(t.db.prepare('SELECT author_subject FROM comments WHERE id = ?').get(c.id).author_subject, null);++ assert.strictEqual((await t.db.prepare('SELECT author_subject FROM comments WHERE id = ?').get(c.id)).author_subject, null);assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');});@@@ -289,7 +289,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual((await call(`/api/v1/comments/threads/${th.access_id}`, { token: svcW })).status, 200, 'and by access id');// Browsers walking ids 1..N find nothing, signed in or not, staff included.-- const max = t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get().m;++ const max = (await t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get()).m;for (let id = 1; id <= max; id++) {for (const who of [{}, { cookie: samJwt }, { token: alexJwt }, { cookie: adminJwt }]) {const r = await call(`/api/v1/comments/threads/${id}`, who);@@@ -314,34 +314,10 @@ const { checkCapability } = require('../server/identity/capabilities');const again = await resolve({ service: 'live', type: 'stream', id: '123' }, { cookie: samJwt });assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');assert.match(vod.id, /^cth_/);-- const all = t.db.prepare('SELECT access_id FROM comment_threads').all().map((r) => r.access_id);++ const all = (await t.db.prepare('SELECT access_id FROM comment_threads').all()).map((r) => r.access_id);assert.ok(all.every(Boolean) && new Set(all).size === all.length, 'every thread has its own');});-- await check('threads made before access ids get one on boot (migration is idempotent)', async () => {-- const fs = require('fs'), os = require('os'), path = require('path');-- const Database = require('better-sqlite3');-- const { openDb } = require('../server/db');-- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-mig-')), 'old.db');-- const old = new Database(file);-- old.exec(`CREATE TABLE comment_threads (id INTEGER PRIMARY KEY AUTOINCREMENT, ref_service TEXT NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL, ref_label TEXT,-- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')), comment_count INTEGER NOT NULL DEFAULT 0, created_by TEXT,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (ref_service, ref_type, ref_id))`);-- for (let i = 0; i < 5; i++) old.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', ?)").run(String(i));-- old.close();-- const db = openDb(file);-- const rows = db.prepare('SELECT id, access_id FROM comment_threads').all();-- assert.strictEqual(rows.length, 5);-- for (const r of rows) assert.match(r.access_id, /^cth_[A-Za-z0-9_-]{22}$/);-- assert.strictEqual(new Set(rows.map((r) => r.access_id)).size, 5);-- db.close();-- const db2 = openDb(file);-- assert.deepStrictEqual(db2.prepare('SELECT id, access_id FROM comment_threads').all(), rows, 'a second boot changes nothing');-- assert.throws(() => db2.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?').run(rows[0].access_id, rows[1].id), /UNIQUE/);-- db2.close();-- fs.rmSync(path.dirname(file), { recursive: true, force: true });-- });--await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });assert.strictEqual(pre.status, 204);@@@ -420,7 +396,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.ok(ok.json().comment.edited_at, 'edited_at set');const byBrowser = await patch({ message: 'typo here!' }, { cookie: alexJwt });assert.strictEqual(byBrowser.status, 200, 'the author from a browser too');-- assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(c.id).message, 'typo here!');++ assert.strictEqual((await t.db.prepare('SELECT message FROM comments WHERE id = ?').get(c.id)).message, 'typo here!');await callL(`/api/v1/comments/${c.id}`, { method: 'DELETE', ...asLive(alex.subject_id) });assert.strictEqual((await patch({ message: 'back from the dead' }, asLive(alex.subject_id))).status, 404);});@@@ -464,25 +440,6 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual((await callL(`/c/${liveVod.access_id}`)).status, 200);});-- await check('comments.edited_at is added to databases made before it (idempotent)', async () => {-- const fs = require('fs'), os = require('os'), path = require('path');-- const Database = require('better-sqlite3');-- const { openDb } = require('../server/db');-- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-edit-')), 'old.db');-- const old = new Database(file);-- old.exec(`CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, parent_id INTEGER, author_subject TEXT, anon_name TEXT,-- origin TEXT NOT NULL DEFAULT 'user', message TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, upvotes INTEGER NOT NULL DEFAULT 0, downvotes INTEGER NOT NULL DEFAULT 0,-- reply_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, deleted_by TEXT)`);-- old.prepare("INSERT INTO comments (thread_id, message) VALUES (1, 'kept')").run();-- old.close();-- const db = openDb(file);-- assert.ok(db.prepare('PRAGMA table_info(comments)').all().some((c) => c.name === 'edited_at'));-- assert.strictEqual(db.prepare('SELECT message, edited_at FROM comments').get().message, 'kept');-- db.close();-- openDb(file).close();-- fs.rmSync(path.dirname(file), { recursive: true, force: true });-- });--await t.close();done();})();@@@ -6,101 +6,103 @@*/const assert = require('assert');const contracts = require('openvibe-contracts');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const events = require('../server/events');const pastes = require('../server/pastes/store');const forum = require('../server/forum/store');const comments = require('../server/comments/store');--const db = openDb(':memory:');--// A relay that never publishes during the test: the rows stay in the outbox for inspection.--events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });--const queued = () => db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope));--const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';--const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };++(async () => {++ const db = await testDb();++ // A relay that never publishes during the test: the rows stay in the outbox for inspection.++ events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });++ const queued = async () => (await db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : r.envelope));++ const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };--let n = 0;--function check(name, fn) { fn(); n++; console.log(` ✓ ${name}`); }++ let n = 0;++ async function check(name, fn) { await fn(); n++; console.log(` ✓ ${name}`); }--check('a paste create, edit, visibility change and delete each queue one event, never the content', () => {-- const p = pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'secret title', content: 'secret body', language: 'text', visibility: 'public' });-- pastes.updatePaste(db, p.id, { content: 'edited body' }, USR);-- pastes.setVisibility(db, p.id, 'unlisted');-- pastes.softDelete(db, p.id);-- const evs = queued();-- assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.paste.created', 'community.paste.updated', 'community.paste.updated', 'community.paste.deleted']);-- evs.forEach(ok);-- assert.ok(!JSON.stringify(evs).includes('secret'), 'no title or content in any event');-- assert.strictEqual(evs[0].visibility, 'public');-- assert.strictEqual(evs[0].payload.url, 'https://openvibe.community/p/amber-fox-42'.replace('https://openvibe.community', require('../server/config').baseUrl));-- assert.deepStrictEqual(evs[1].payload.changed, ['content']);-- assert.strictEqual(evs[2].visibility, 'internal', 'an unlisted paste is not a public event');-- assert.strictEqual(evs[2].payload.url, null);-- assert.deepStrictEqual(evs[0].actor, { type: 'user', id: USR });--});++ await check('a paste create, edit, visibility change and delete each queue one event, never the content', async () => {++ const p = await pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'secret title', content: 'secret body', language: 'text', visibility: 'public' });++ await pastes.updatePaste(db, p.id, { content: 'edited body' }, USR);++ await pastes.setVisibility(db, p.id, 'unlisted');++ await pastes.softDelete(db, p.id);++ const evs = await queued();++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.paste.created', 'community.paste.updated', 'community.paste.updated', 'community.paste.deleted']);++ evs.forEach(ok);++ assert.ok(!JSON.stringify(evs).includes('secret'), 'no title or content in any event');++ assert.strictEqual(evs[0].visibility, 'public');++ assert.strictEqual(evs[0].payload.url, 'https://openvibe.community/p/amber-fox-42'.replace('https://openvibe.community', require('../server/config').baseUrl));++ assert.deepStrictEqual(evs[1].payload.changed, ['content']);++ assert.strictEqual(evs[2].visibility, 'internal', 'an unlisted paste is not a public event');++ assert.strictEqual(evs[2].payload.url, null);++ assert.deepStrictEqual(evs[0].actor, { type: 'user', id: USR });++ });--check('a write that fails queues nothing (the event is in its transaction)', () => {-- const before = queued().length;-- assert.throws(() => pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, type: 'paste', content: 'dup', visibility: 'public' }), /UNIQUE/);-- assert.strictEqual(queued().length, before);--});++ await check('a write that fails queues nothing (the event is in its transaction)', async () => {++ const before = (await queued()).length;++ await assert.rejects(async () => await pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, type: 'paste', content: 'dup', visibility: 'public' }), /duplicate key/);++ assert.strictEqual((await queued()).length, before);++ });--check('threads and posts: public spaces only are public events; staff spaces are members-only', () => {-- db.prepare("INSERT OR IGNORE INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff')").run();-- const general = db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get().id;-- const staff = db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get().id;-- const before = queued().length;-- const { thread } = forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: 'first post body' });-- forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });-- forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden body' });-- const evs = queued().slice(before);-- assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);-- evs.forEach(ok);-- assert.strictEqual(evs[0].visibility, 'public');-- assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));-- assert.strictEqual(evs[2].visibility, 'internal');-- assert.strictEqual(evs[2].payload.visibility, 'members');-- assert.strictEqual(evs[2].payload.url, null);-- assert.ok(!JSON.stringify(evs).includes('body'), 'no post body');--});++ await check('threads and posts: public spaces only are public events; staff spaces are members-only', async () => {++ await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff') ON CONFLICT DO NOTHING").run();++ const general = (await db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get()).id;++ const staff = (await db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get()).id;++ const before = (await queued()).length;++ const { thread } = await forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: 'first post body' });++ await forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });++ await forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden body' });++ const evs = (await queued()).slice(before);++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);++ evs.forEach(ok);++ assert.strictEqual(evs[0].visibility, 'public');++ assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));++ assert.strictEqual(evs[2].visibility, 'internal');++ assert.strictEqual(evs[2].payload.visibility, 'members');++ assert.strictEqual(evs[2].payload.url, null);++ assert.ok(!JSON.stringify(evs).includes('body'), 'no post body');++ });--check('a comment on a Live VOD queues community.comment.created with its ref', () => {-- const before = queued().length;-- const t = comments.resolveThread(db, { service: 'live', type: 'vod', id: '906' }).thread;-- comments.insertComment(db, { thread_id: t.id, author_subject: USR, message: 'nice stream' });-- const evs = queued().slice(before);-- assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.comment.created']);-- evs.forEach(ok);-- assert.deepStrictEqual(evs[0].payload.ref, { service: 'live', type: 'vod', id: '906' });-- assert.ok(!JSON.stringify(evs).includes('nice stream'));--});++ await check('a comment on a Live VOD queues community.comment.created with its ref', async () => {++ const before = (await queued()).length;++ const t = (await comments.resolveThread(db, { service: 'live', type: 'vod', id: '906' })).thread;++ await comments.insertComment(db, { thread_id: t.id, author_subject: USR, message: 'nice stream' });++ const evs = (await queued()).slice(before);++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.comment.created']);++ evs.forEach(ok);++ assert.deepStrictEqual(evs[0].payload.ref, { service: 'live', type: 'vod', id: '906' });++ assert.ok(!JSON.stringify(evs).includes('nice stream'));++ });--check('staff actions on someone else\'s content go to the moderation audit log (ADR-022); an owner\'s own do not', () => {-- const { createPasteService } = require('../server/pastes/service');-- const svc = createPasteService({ db });-- const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';-- pastes.insertPaste(db, { slug: 'mod-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'secret words', language: 'text', visibility: 'public' });-- pastes.insertPaste(db, { slug: 'own-paste-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });-- const before = queued().length;-- svc.remove({ kind: 'user', subject: STAFF, staff: true }, 'mod-target-1');-- svc.remove({ kind: 'user', subject: USR }, 'own-paste-1');-- const evs = queued().slice(before);-- const mod = evs.filter((e) => e.event_type === 'community.moderation.action');-- assert.strictEqual(mod.length, 1, 'only the staff delete is audited');-- ok(mod[0]);-- assert.deepStrictEqual(mod[0].payload.target, { type: 'paste', id: 'mod-target-1', owner_subject: USR });-- assert.strictEqual(mod[0].payload.actor_subject, STAFF);-- assert.deepStrictEqual(mod[0].actor, { type: 'user', id: STAFF });-- assert.ok(!JSON.stringify(mod).includes('secret words'), 'never the content');-- pastes.insertPaste(db, { slug: 'bulk-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });-- const bulk = svc.bulk({ slugs: ['bulk-target-1', 'nope'], action: 'private' }, { kind: 'user', subject: STAFF, staff: true });-- assert.deepStrictEqual([bulk.done, bulk.skipped], [1, 1]);-- const last = queued().slice(-1)[0];-- assert.strictEqual(last.event_type, 'community.moderation.action');-- assert.deepStrictEqual(last.payload.details, { bulk_action: 'private', done: bulk.done, skipped: bulk.skipped });-- ok(last);--});++ await check('staff actions on someone else\'s content go to the moderation audit log (ADR-022); an owner\'s own do not', async () => {++ const { createPasteService } = require('../server/pastes/service');++ const svc = createPasteService({ db });++ const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++ await pastes.insertPaste(db, { slug: 'mod-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'secret words', language: 'text', visibility: 'public' });++ await pastes.insertPaste(db, { slug: 'own-paste-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });++ const before = (await queued()).length;++ await svc.remove({ kind: 'user', subject: STAFF, staff: true }, 'mod-target-1');++ await svc.remove({ kind: 'user', subject: USR }, 'own-paste-1');++ const evs = (await queued()).slice(before);++ const mod = evs.filter((e) => e.event_type === 'community.moderation.action');++ assert.strictEqual(mod.length, 1, 'only the staff delete is audited');++ ok(mod[0]);++ assert.deepStrictEqual(mod[0].payload.target, { type: 'paste', id: 'mod-target-1', owner_subject: USR });++ assert.strictEqual(mod[0].payload.actor_subject, STAFF);++ assert.deepStrictEqual(mod[0].actor, { type: 'user', id: STAFF });++ assert.ok(!JSON.stringify(mod).includes('secret words'), 'never the content');++ await pastes.insertPaste(db, { slug: 'bulk-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });++ const bulk = await svc.bulk({ slugs: ['bulk-target-1', 'nope'], action: 'private' }, { kind: 'user', subject: STAFF, staff: true });++ assert.deepStrictEqual([bulk.done, bulk.skipped], [1, 1]);++ const last = (await queued()).slice(-1)[0];++ assert.strictEqual(last.event_type, 'community.moderation.action');++ assert.deepStrictEqual(last.payload.details, { bulk_action: 'private', done: bulk.done, skipped: bulk.skipped });++ ok(last);++ });--events._reset();--console.log(`community events: ${n} checks passed`);--process.exit(0);++ events._reset();++ console.log(`community events: ${n} checks passed`);++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -16,7 +16,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString(async () => {const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });// These pages are the feed style (render/forum.js): General is a forum board by default, so it becomes a feed with votes here.-- t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();++ await t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();const net = t.network;const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });@@@ -130,8 +130,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString});await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {-- const space = forumStore.getSpace(t.db, 'showcase');-- for (let i = 1; i <= 27; i++) forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });++ const space = await forumStore.getSpace(t.db, 'showcase');++ for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });const p1 = await t.get('/s/showcase?sort=new');assert.strictEqual(p1.status, 200);has(p1.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new">');@@@ -149,9 +149,9 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString});await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {-- t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();-- const ins = forumStore.getSpace(t.db, 'insiders');-- forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });++ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();++ const ins = await forumStore.getSpace(t.db, 'insiders');++ await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });const anon = await t.get('/s/insiders');assert.strictEqual(anon.status, 303);assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');@@@ -171,8 +171,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString});await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {-- const space = forumStore.getSpace(t.db, 'feedback');-- forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });++ const space = await forumStore.getSpace(t.db, 'feedback');++ await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });const r = await t.get('/s/feedback/t/weekly-summary');has(r.text, 'OpenVibe AI');has(r.text, '<span class="badge badge-ai"');@@@ -26,7 +26,7 @@ const { boot, check, done } = require('./helpers/app');if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });};-- const pasteRow = (slug, extra = {}) => t.db.prepare('INSERT INTO pastes (slug, owner_subject, title, content, language, visibility, burn_after_read) VALUES (?, ?, ?, ?, ?, ?, ?)')++ const pasteRow = async (slug, extra = {}) => await t.db.prepare('INSERT INTO pastes (slug, owner_subject, title, content, language, visibility, burn_after_read) VALUES (?, ?, ?, ?, ?, ?, ?)').run(slug, alex.subject_id, extra.title || 'Snippet', extra.content || 'const a = 1;\nconsole.log(a);', 'javascript', extra.visibility || 'public', extra.burn ? 1 : 0);let topic;@@@ -81,10 +81,10 @@ const { boot, check, done } = require('./helpers/app');assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));-- t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();++ await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');assert.deepStrictEqual((await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0].reactions, []);-- t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();++ await t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();});await check('quote: ?quote= fills the reply box with the quoted post', async () => {@@@ -94,7 +94,7 @@ const { boot, check, done } = require('./helpers/app');});await check('pastes on posts: public and unlisted as cards; private and burn-after-read refused', async () => {-- pasteRow('snip1'); pasteRow('snip2', { visibility: 'unlisted', title: 'Hidden-ish' }); pasteRow('secret1', { visibility: 'private' }); pasteRow('burn1', { burn: true });++ await pasteRow('snip1'); await pasteRow('snip2', { visibility: 'unlisted', title: 'Hidden-ish' }); await pasteRow('secret1', { visibility: 'private' }); await pasteRow('burn1', { burn: true });let r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'look at these', pastes: 'https://openvibe.community/p/snip1 snip2' } });assert.strictEqual(r.status, 201, r.text);assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);@@@ -102,7 +102,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);const html = (await call('/s/general/t/welcome-thread')).text;assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));-- t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();++ await t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');assert.deepStrictEqual(after.pastes.map((p) => p.slug), ['snip1'], 'a paste made private drops out');});@@@ -80,7 +80,7 @@ const forumStore = require('../server/forum/store');assert.strictEqual(r.json().thread.origin, 'ai');assert.strictEqual(r.json().thread.author.is_ai, true);assert.strictEqual(r.json().thread.author.subject, null);-- assert.strictEqual(t.db.prepare('SELECT author_subject FROM threads WHERE id = ?').get(r.json().thread.id).author_subject, null);++ assert.strictEqual((await t.db.prepare('SELECT author_subject FROM threads WHERE id = ?').get(r.json().thread.id)).author_subject, null);});await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {@@@ -122,7 +122,7 @@ const forumStore = require('../server/forum/store');const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });const off = await vote(1, samJwt);assert.strictEqual(off.status, 403, 'General is a forum-style board: votes are off'); assert.strictEqual(off.json().code, 'space.votes_off');-- t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();++ await t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();assert.strictEqual((await vote(1, samJwt)).json().score, 1);assert.strictEqual((await vote(1, alexJwt)).json().score, 2);assert.strictEqual((await vote(-1, samJwt)).json().score, 0);@@@ -133,31 +133,31 @@ const forumStore = require('../server/forum/store');});await check('sorting: new, top, hot (deterministic formula at a fixed now), pinned first, pagination', async () => {-- const space = forumStore.getSpace(t.db, 'showcase');-- const mk = (title, score, hoursAgo) => {-- const { thread } = forumStore.createThread(t.db, { space_id: space.id, title, author_subject: alex.subject_id, body_markdown: 'x' });-- t.db.prepare("UPDATE threads SET score = ?, created_at = datetime('2026-09-22 12:00:00', ?), last_activity_at = datetime('2026-09-22 12:00:00', ?) WHERE id = ?")++ const space = await forumStore.getSpace(t.db, 'showcase');++ const mk = async (title, score, hoursAgo) => {++ const { thread } = await forumStore.createThread(t.db, { space_id: space.id, title, author_subject: alex.subject_id, body_markdown: 'x' });++ await t.db.prepare("UPDATE threads SET score = ?, created_at = datetime('2026-09-22 12:00:00', ?), last_activity_at = datetime('2026-09-22 12:00:00', ?) WHERE id = ?").run(score, `-${hoursAgo} hours`, `-${hoursAgo} hours`, thread.id);return thread;};-- t.db.prepare('DELETE FROM threads WHERE space_id = ?').run(space.id);-- mk('old popular', 50, 48); // (51)/(50^1.5) ≈ 0.144-- mk('fresh quiet', 0, 1); // (1)/(3^1.5) ≈ 0.192-- mk('fresh liked', 5, 2); // (6)/(4^1.5) = 0.75-- mk('ancient', 3, 500);++ await t.db.prepare('DELETE FROM threads WHERE space_id = ?').run(space.id);++ await mk('old popular', 50, 48); // (51)/(50^1.5) ≈ 0.144++ await mk('fresh quiet', 0, 1); // (1)/(3^1.5) ≈ 0.192++ await mk('fresh liked', 5, 2); // (6)/(4^1.5) = 0.75++ await mk('ancient', 3, 500);const now = new Date('2026-09-22T12:00:00Z');const titles = async (sort, extra = {}) => (await forum.listThreads(asAlex, 'showcase', { sort, now, ...extra })).threads.map((x) => x.title);assert.deepStrictEqual(await titles('hot'), ['fresh liked', 'fresh quiet', 'old popular', 'ancient']);assert.deepStrictEqual(await titles('hot'), await titles('hot'), 'same inputs, same order');assert.deepStrictEqual(await titles('new'), ['fresh quiet', 'fresh liked', 'old popular', 'ancient']);assert.deepStrictEqual(await titles('top'), ['old popular', 'fresh liked', 'ancient', 'fresh quiet']);-- const ancient = t.db.prepare("SELECT id FROM threads WHERE title = 'ancient'").get().id;-- t.db.prepare('UPDATE threads SET pinned = 1 WHERE id = ?').run(ancient);++ const ancient = (await t.db.prepare("SELECT id FROM threads WHERE title = 'ancient'").get()).id;++ await t.db.prepare('UPDATE threads SET pinned = 1 WHERE id = ?').run(ancient);assert.strictEqual((await titles('hot'))[0], 'ancient');const p1 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3 });const p2 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3, page: 2 });assert.deepStrictEqual([p1.pages, p1.total, p1.threads.length, p2.threads.length], [2, 4, 3, 1]);-- const hot = t.db.prepare('SELECT ov_hot(5, 2) AS h').get().h;++ const hot = (await t.db.prepare('SELECT ov_hot(5, 2) AS h').get()).h;assert.ok(Math.abs(hot - 6 / Math.pow(4, 1.5)) < 1e-12, 'ov_hot = (score + 1) / (hours + 2)^1.5');});@@@ -180,7 +180,7 @@ const forumStore = require('../server/forum/store');});await check('members and staff spaces: sign-in for members, invisible for non-staff', async () => {-- t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();++ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();assert.strictEqual((await call('/api/v1/spaces/insiders/threads')).status, 401);assert.strictEqual((await call('/api/v1/spaces/insiders/threads', { cookie: samJwt })).status, 200);assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: samJwt })).status, 404);@@@ -6,8 +6,9 @@ const mockNetwork = require('./mock-network');const mockMedia = require('./mock-media');/**-- * opts.authority 'live' (default) or 'community' — the latter runs the native paste API on a-- * fresh in-memory database, with Network and Media mocks behind it.++ * opts.authority 'live' (default) or 'community' — the latter runs the native paste API, with Network and++ * Media mocks behind it. Every boot gets a migrated database of its own (./db.js: PGlite, or the++ * PostgreSQL containers under npm run test:pg, with Valkey for the limit counters).* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).* opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET@@@ -31,16 +32,18 @@ async function boot(opts = {}) {process.env.TRUST_PROXY = '1';process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;process.env.PASTES_AUTHORITY = opts.authority || 'live';-- process.env.COMMUNITY_DB_PATH = ':memory:';++ // test:pg: the Valkey container too, under a prefix of its own.++ if (process.env.COMMUNITY_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL) {++ process.env.VALKEY_URL = process.env.OV_TEST_VALKEY_URL;++ process.env.VALKEY_PREFIX = `ov:community-test:${process.pid}:${Math.random().toString(36).slice(2, 10)}:`;++ }Object.assign(process.env, opts.env || {});for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];const { createApp } = require('../../server/app');const appOpts = { ...(opts.appOpts || {}) };-- if (opts.authority === 'community') {-- appOpts.db = require('../../server/db').openDb(':memory:');-- appOpts.pasteLimits = opts.pasteLimits;-- }-- const app = createApp(appOpts);++ appOpts.db = appOpts.db || await require('./db').testDb();++ if (opts.authority === 'community') appOpts.pasteLimits = opts.pasteLimits;++ const app = await createApp(appOpts);const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -61,14 +64,18 @@ async function boot(opts = {}) {}return {app, base, get, jar, live: liveSrv, network: netSrv, media: mediaSrv, db: appOpts.db || null,-- close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); await mediaSrv.close(); },++ close: async () => {++ await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); await mediaSrv.close();++ if (app.locals.valkey) await app.locals.valkey.close().catch(() => {});++ await appOpts.db.close().catch(() => {});++ },};}let failures = 0;async function check(name, fn) {try { await fn(); console.log(' ✓', name); }-- catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 4).join('\n ')); }++ catch (e) { failures++; console.log(" ✗", name, "\n ", (e.stack || String(e)).split("\n").slice(0, process.env.DEBUG ? 40 : 4).join("\n ")); }}function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++/**++ * A migrated database for one test (ADR-035), from openvibe-sdk/testing: PGlite by default; with COMMUNITY_TEST_STORE=pg++ * (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of its own. The handle's close()++ * also drops the test database.++ */++const { createTestDb } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++async function testDb({ store = process.env.COMMUNITY_TEST_STORE || 'pglite', max = 4 } = {}) {++ const t = await createTestDb({ migrations: MIGRATIONS, store, service: 'community', max });++ // db.close() runs the test database's own close (PGlite: closes it; the containers: also drops its roles and++ // schema), once; that close calls the handle's original close, which is put back first.++ const own = t.db.close;++ let closing = null;++ t.db.close = () => { if (!closing) { t.db.close = own; closing = t.close(); } return closing; };++ return t.db;++}++++module.exports = { testDb };ddeleted file mode 100644@@@ -1,184 +0,0 @@--'use strict';--/**-- * Live's own VOD/clip comments → Community threads (server/comments/live-import.js and-- * scripts/import-live-comments.js): reconciliation (read = imported + held + excluded), authors-- * from Live's links and the Network's identity map (unmapped and disagreeing ones held), replies-- * nested one level, times and edits kept, the ledger making re-runs import nothing twice, held rows-- * imported once their author maps, dry run writing nothing, and --apply refusing without a backup.-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { spawnSync } = require('child_process');--const Database = require('better-sqlite3');--const { check, done } = require('./helpers/app');----process.env.NODE_ENV = 'test';--const { openDb } = require('../server/db');--const { importLiveComments, formatReport } = require('../server/comments/live-import');--const { createCommentService } = require('../server/comments/service');----const A = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'; // live user 1, linked on Live--const B = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB'; // live user 2, Network map only--const C = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC'; // live user 4 per Live's link…--const D = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD'; // …and per the Network: they disagree--const E = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE'; // live user 3, linked later----const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-comments-'));--const livePath = path.join(dir, 'live.db');--const live = new Database(livePath);--live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);--CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);--CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')), content_id INTEGER NOT NULL,-- user_id INTEGER NOT NULL, parent_id INTEGER, message TEXT NOT NULL, is_deleted INTEGER DEFAULT 0,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);--live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?)").run(A);--live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (4, 'network', '104', ?)").run(C);--live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'twitch', 'x', NULL)").run();--const add = (id, type, cid, uid, parent, message, extra = {}) => live.prepare(`INSERT INTO comments (id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, type, cid, uid, parent, message, extra.deleted ? 1 : 0, extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`, extra.updated || extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`);--add(1, 'vod', 10, 1, null, 'first');--add(2, 'vod', 10, 2, 1, 'a reply');--add(3, 'vod', 10, 1, 2, 'reply to the reply');--add(4, 'vod', 10, 3, null, 'by someone unmapped');--add(5, 'vod', 10, 1, 4, 'under the held one');--add(6, 'clip', 20, 4, null, 'two answers for this author');--add(7, 'clip', 20, 1, null, 'deleted on live', { deleted: true });--add(8, 'clip', 20, 2, 7, 'reply under a deleted comment');--add(9, 'clip', 20, 1, null, 'edited once', { created: '2026-08-09 10:00:00', updated: '2026-08-09 11:30:00' });--add(10, 'vod', 11, 1, 99, 'parent missing');--add(11, 'vod', 10, 2, 9, 'parent on other content');--add(12, 'vod', 12, 1, null, ' ');--live.close();----const networkMap = { 2: B, 4: D };--const calls = [];--const resolveLegacy = async (system, ids) => {-- calls.push({ system, ids });-- return new Map(ids.map((id) => [id, networkMap[id] ? { subject: { type: 'user', id: networkMap[id] } } : null]));--};----(async () => {-- const db = openDb(':memory:');-- const liveRo = new Database(livePath, { readonly: true });-- const count = (sql, ...a) => db.prepare(sql).get(...a).n;-- const threadOf = (type, id) => db.prepare("SELECT * FROM comment_threads WHERE ref_service = 'live' AND ref_type = ? AND ref_id = ?").get(type, String(id));---- await check('dry run (the default): the full report, nothing written', async () => {-- const r = await importLiveComments(db, liveRo, { resolveLegacy });-- assert.strictEqual(r.dry_run, true);-- assert.deepStrictEqual([r.read, r.imported, r.held, r.excluded], [12, 4, 3, 5]);-- assert.strictEqual(r.reconciled, true);-- assert.deepStrictEqual(r.excluded_by_reason, { deleted: 1, parent_deleted: 1, orphan: 2, empty: 1 });-- assert.deepStrictEqual(r.held_rows.map((h) => [h.live_id, h.reason]), [[4, 'unmapped_author'], [6, 'ambiguous_author'], [5, 'parent_held']]);-- assert.deepStrictEqual(calls[0], { system: 'live', ids: ['1', '2', '3', '4'] }, 'only authors of importable rows are looked up');-- for (const table of ['comments', 'comment_threads', 'legacy_id_map', 'import_hold', 'migration_runs']) {-- assert.strictEqual(count(`SELECT COUNT(*) AS n FROM ${table}`), 0, `${table} untouched`);-- }-- const text = formatReport(r);-- assert.match(text, /DRY RUN/);-- assert.match(text, /read 12 = imported 4 \+ held 3 \+ excluded 5 → OK/);-- assert.match(text, /live comment 4 {2}unmapped_author {2}on vod\/10 {2}live user 3/);-- });---- await check('apply: threads per VOD/clip, authors as subjects, one level of nesting, times and edits kept, counters, holds, a run record', async () => {-- const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false, source: 'live.db' });-- assert.deepStrictEqual([r.read, r.imported_new, r.imported_before, r.held, r.excluded], [12, 4, 0, 3, 5]);-- const vod = threadOf('vod', 10), clip = threadOf('clip', 20);-- assert.ok(vod && clip && !threadOf('vod', 11) && !threadOf('vod', 12), 'threads only where something was imported');-- assert.strictEqual(r.threads.length, 2);-- assert.strictEqual(vod.comment_count, 3);-- assert.strictEqual(clip.comment_count, 1);-- const idOf = (liveId) => Number(db.prepare("SELECT target_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND source_id = ?").get(String(liveId)).target_id);-- const row = (liveId) => db.prepare('SELECT * FROM comments WHERE id = ?').get(idOf(liveId));-- assert.deepStrictEqual([row(1).author_subject, row(1).parent_id, row(1).message, row(1).created_at, row(1).edited_at], [A, null, 'first', '2026-08-01 10:00:00', null]);-- assert.deepStrictEqual([row(2).author_subject, row(2).parent_id], [B, idOf(1)], 'a Network-only author');-- assert.strictEqual(row(3).parent_id, idOf(1), 'a reply to a reply joins the top-level comment');-- assert.strictEqual(row(1).reply_count, 2);-- assert.deepStrictEqual([row(9).created_at, row(9).edited_at], ['2026-08-09 10:00:00', '2026-08-09 11:30:00']);-- assert.strictEqual(row(9).origin, 'user');-- const holds = db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment' ORDER BY source_id").all();-- assert.deepStrictEqual(holds.map((h) => [h.source_id, h.reason]), [['4', 'unmapped_author'], ['5', 'parent_held'], ['6', 'ambiguous_author']]);-- assert.strictEqual(count("SELECT COUNT(*) AS n FROM migration_runs WHERE source = 'live-comments:live.db'"), 1);---- // What Live's adapter reads back: newest first, replies nested, edits marked.-- const svc = createCommentService({ db });-- const page = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, vod.id, { sort: 'new' });-- assert.deepStrictEqual(page.comments.map((c) => c.message), ['first']);-- assert.deepStrictEqual(page.comments[0].replies.map((c) => c.message), ['a reply', 'reply to the reply']);-- const clipPage = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, clip.id, {});-- assert.strictEqual(clipPage.comments[0].edited_at, '2026-08-09T11:30:00.000Z');-- });---- await check('the ledger: a second run imports nothing twice', async () => {-- const before = count('SELECT COUNT(*) AS n FROM comments');-- const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });-- assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded, r.reconciled], [0, 4, 3, 5, true]);-- assert.strictEqual(count('SELECT COUNT(*) AS n FROM comments'), before);-- assert.strictEqual(threadOf('vod', 10).comment_count, 3);-- });---- await check('held rows import once their author maps; their holds are cleared; replies follow', async () => {-- const rw = new Database(livePath);-- rw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (3, 'network', '103', ?)").run(E);-- rw.close();-- const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });-- assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded], [2, 4, 1, 5]);-- assert.deepStrictEqual(db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment'").all(), [{ source_id: '6', reason: 'ambiguous_author' }]);-- assert.strictEqual(threadOf('vod', 10).comment_count, 5);-- const liveIdOf = (communityId) => db.prepare("SELECT source_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND target_id = ?").get(String(communityId)).source_id;-- const reply = db.prepare("SELECT * FROM comments WHERE message = 'under the held one'").get();-- assert.strictEqual(liveIdOf(reply.parent_id), '4');-- });---- await check('Live links only (no Network call): the Network-only author is held', async () => {-- const fresh = openDb(':memory:');-- const r = await importLiveComments(fresh, liveRo, { resolveLegacy: null });-- assert.strictEqual(r.reconciled, true);-- assert.ok(r.held_rows.some((h) => h.live_id === 2 && h.reason === 'unmapped_author'));-- assert.ok(!r.held_rows.some((h) => h.live_id === 3), 'a reply to it joins the top-level comment, which is not held');-- assert.ok(!r.held_rows.some((h) => h.live_id === 6), 'with one source there is nothing to disagree');-- fresh.close();-- });---- await check('the script: dry run by default; --apply refused without --backup; the backup is taken and checked; re-runs are no-ops', async () => {-- const communityPath = path.join(dir, 'community.db');-- openDb(communityPath).close();-- const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'import-live-comments.js'), ...args], {-- cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, COMMUNITY_DB_PATH: communityPath, OV_OAUTH_CLIENT_SECRET: '' },-- });-- const base = ['--live-db', livePath, '--community-db', communityPath, '--live-links-only'];-- const dry = run(...base);-- assert.strictEqual(dry.status, 0, dry.stderr);-- assert.match(dry.stdout, /DRY RUN/);-- assert.match(dry.stdout, /Nothing was written/);-- const peek = () => { const d = new Database(communityPath, { readonly: true }); const n = d.prepare('SELECT COUNT(*) AS n FROM comments').get().n; d.close(); return n; };-- assert.strictEqual(peek(), 0);-- const noBackup = run(...base, '--apply');-- assert.strictEqual(noBackup.status, 2);-- assert.match(noBackup.stderr, /--apply needs --backup/);-- assert.strictEqual(peek(), 0);-- const backup = path.join(dir, 'community-before.db');-- const applied = run(...base, '--apply', '--backup', backup);-- assert.strictEqual(applied.status, 0, applied.stderr);-- assert.match(applied.stdout, /Backup: .*community-before\.db \(integrity ok; 0 comments in 0 threads\)/);-- assert.match(applied.stdout, /APPLIED/);-- assert.ok(fs.existsSync(backup));-- const imported = peek();-- assert.ok(imported > 0);-- const again = run(...base, '--apply', '--backup', backup);-- assert.strictEqual(again.status, 2, 'an existing backup file is never overwritten');-- const again2 = run(...base, '--apply', '--backup', path.join(dir, 'community-before-2.db'));-- assert.strictEqual(again2.status, 0, again2.stderr);-- assert.match(again2.stdout, /new 0, already imported/);-- assert.strictEqual(peek(), imported);-- const missing = run('--live-db', path.join(dir, 'nope.db'), '--community-db', communityPath);-- assert.strictEqual(missing.status, 2);-- });---- liveRo.close();-- db.close();-- fs.rmSync(dir, { recursive: true, force: true });-- done();--})();ddeleted file mode 100644@@@ -1,275 +0,0 @@--'use strict';--/**-- * scripts/import-pastes.js + server/pastes/importer.js: bundle verification, owner mapping across-- * the id-space cutoff (ambiguous / unmapped → holds), AI pastes, fork and reply remapping, likes,-- * idempotent re-runs, newer bundles, dry runs, and the CLI's exit codes.-- */--const assert = require('assert');--const crypto = require('crypto');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { spawnSync } = require('child_process');--const { openDb } = require('../server/db');--const { importBundle, ImportError } = require('../server/pastes/importer');--const { createNetworkIdentity } = require('../server/identity/network');--const mockNetwork = require('./helpers/mock-network');--const { check, done } = require('./helpers/app');----const PRE = '2026-08-18 10:00:00'; // between the OpenVibe launch (2026-08-17T21:10:29Z) and the id-space fix (2026-08-20T03:00:26Z)--const MIGRATED = '2026-08-01 10:00:00'; // before the launch: rows migrated from HoboStreamer, Live ids--const POST = '2026-09-01 10:00:00';----function seal(bundle) {-- const { pastes, comments, likes } = bundle;-- return {-- format: 'openvibe.media.pastes-export', version: 1, app: 'live', generated_at: '2026-09-22T12:00:00Z',-- since_id: 0, max_id: Math.max(0, ...pastes.map((p) => p.id)),-- counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },-- sha256: crypto.createHash('sha256').update(JSON.stringify({ pastes, comments, likes })).digest('hex'),-- pastes, comments, likes,-- };--}----const paste = (o) => ({-- id: 1, slug: 'amber-fox-11', user_id: null, type: 'paste', title: 'T', content: 'c', language: 'text', visibility: 'public',-- stream_id: null, screenshot_url: null, metadata: null, burn_after_read: 0, forked_from: null, pinned: 0, views: 0, unique_views: 0,-- copies: 0, likes: 0, is_nsfw: 0, ai_summary: null, ai_tags: null, ai_analyzed_at: null, created_at: POST, updated_at: POST, ...o,--});----(async () => {-- const net = await mockNetwork.start();-- // A: network 7 = live 107. B: network 9 = live 109. C: live 9 (so "9" is ambiguous before the cutoff).-- const A = net.addUser({ network_user_id: 7, username: 'alex' });-- const B = net.addUser({ network_user_id: 9, username: 'sam' });-- const C = net.addUser({ network_user_id: 30, username: 'cleo' });-- net.directory.legacy.live = { 107: A.subject_id, 109: B.subject_id, 9: C.subject_id };-- const config = { networkInternalUrl: net.url, networkUrl: net.url, oauth: { clientId: 'community', clientSecret: 'shh' } };---- const db = openDb(':memory:');-- const identity = createNetworkIdentity({ config, db });-- const run = (bundle, opts = {}) => importBundle(db, bundle, { resolveLegacy: identity.resolveLegacy, source: 'test', ...opts });-- const count = (table) => db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get().c;-- const bySlug = (slug) => db.prepare('SELECT * FROM pastes WHERE slug = ?').get(slug);-- const holds = () => db.prepare('SELECT source_type, source_id, reason FROM import_hold ORDER BY source_type, source_id, reason').all();---- const pastes = [-- paste({ id: 6, slug: 'fork-of-one-66', user_id: 107, title: 'Fork of One', forked_from: 1 }), // fork listed before its original-- paste({ id: 1, slug: 'amber-fox-11', user_id: 107, title: 'One', content: 'hello', language: 'javascript', views: 50, unique_views: 40, copies: 3, likes: 2, visibility: 'public', is_nsfw: 1, ai_summary: 'greets', ai_tags: ['hi'], ai_analyzed_at: '2026-09-02 00:00:00', created_at: POST, updated_at: '2026-09-03 00:00:00' }),-- paste({ id: 2, slug: 'blue-lake-22', user_id: 7, created_at: PRE, updated_at: PRE, visibility: 'unlisted' }), // pre-cutoff, only the network mapping exists → A-- paste({ id: 3, slug: 'cold-ridge-33', user_id: 9, created_at: PRE, updated_at: PRE }), // pre-cutoff, live 9 = C, network 9 = B → ambiguous-- paste({ id: 4, slug: 'dark-owl-44', user_id: 555 }), // post-cutoff, unmapped-- paste({ id: 5, slug: 'wild-moment-5555', user_id: 109, type: 'screenshot', content: '', screenshot_url: 'https://openvibe.media/p/wild-moment-5555/screenshot', metadata: { ai_moment: true, stream_id: 77 }, ai_summary: 'hype' }),-- paste({ id: 7, slug: 'epic-frame-7777', user_id: 109, type: 'screenshot', screenshot_url: 'https://openvibe.media/p/epic-frame-7777/screenshot', stream_id: 12, metadata: { original_name: 'ai-moment-vod31-340.jpg', mime_type: 'image/jpeg' } }),-- paste({ id: 8, slug: 'gold-gem-88', user_id: 777, created_at: PRE, updated_at: PRE, visibility: 'private' }), // pre-cutoff, no mapping at all-- paste({ id: 9, slug: 'gray-dove-99', user_id: null, title: 'anon' }),-- ];-- const comments = [-- { id: 11, paste_id: 1, user_id: 107, parent_id: null, anon_name: null, message: 'nice', is_deleted: 0, created_at: POST, updated_at: POST },-- { id: 12, paste_id: 1, user_id: null, parent_id: 11, anon_name: 'Visitor', message: 'agreed', is_deleted: 0, created_at: POST, updated_at: POST },-- { id: 13, paste_id: 1, user_id: 555, parent_id: null, anon_name: null, message: 'who am i', is_deleted: 0, created_at: POST, updated_at: POST },-- { id: 14, paste_id: 99, user_id: 107, parent_id: null, anon_name: null, message: 'orphan', is_deleted: 0, created_at: POST, updated_at: POST },-- ];-- const likes = [-- { paste_id: 1, user_id: 107, created_at: POST },-- { paste_id: 1, user_id: 555, created_at: POST },-- { paste_id: 1, user_id: 9, created_at: PRE },-- ];-- const bundle = seal({ pastes, comments, likes });---- await check('refuses a bundle whose format, version, counts or sha256 do not check out', async () => {-- for (const [mutate, re] of [-- [(b) => { b.sha256 = '0'.repeat(64); }, /sha256 mismatch/],-- [(b) => { b.counts.pastes += 1; }, /count mismatch for pastes/],-- [(b) => { b.format = 'something-else'; }, /unexpected format/],-- [(b) => { b.version = 2; }, /unsupported version/],-- [(b) => { b.pastes[0].title = 'tampered'; }, /sha256 mismatch/],-- ]) {-- const b = JSON.parse(JSON.stringify(bundle));-- mutate(b);-- await assert.rejects(run(b), (err) => err instanceof ImportError && re.test(err.message));-- }-- assert.strictEqual(count('pastes'), 0);-- });---- await check('--dry-run reports everything and leaves the database untouched', async () => {-- const r = await run(bundle, { dryRun: true });-- assert.strictEqual(r.dry_run, true);-- assert.strictEqual(r.pastes.inserted, 9);-- for (const t of ['pastes', 'paste_comments', 'paste_likes', 'import_hold', 'legacy_id_map', 'migration_runs']) assert.strictEqual(count(t), 0, t);-- });---- let first;-- await check('import: counters, timestamps, visibility, NSFW and AI fields kept; owners mapped across the cutoff', async () => {-- first = await run(bundle);-- assert.deepStrictEqual(first.pastes, { bundle: 9, inserted: 9, updated: 0, unchanged: 0, held: 3, skipped: 0, ai: 2, forks_remapped: 1, forks_unresolved: 0 });-- const one = bySlug('amber-fox-11');-- assert.strictEqual(one.owner_subject, A.subject_id);-- assert.strictEqual(one.legacy_media_id, 1);-- assert.strictEqual(one.legacy_user_id, 107);-- assert.strictEqual(one.views, 50);-- assert.strictEqual(one.unique_views, 40);-- assert.strictEqual(one.copies, 3);-- assert.strictEqual(one.likes, 2);-- assert.strictEqual(one.is_nsfw, 1);-- assert.strictEqual(one.ai_tags, '["hi"]');-- assert.strictEqual(one.created_at, POST);-- assert.strictEqual(one.updated_at, '2026-09-03 00:00:00');-- assert.strictEqual(one.origin, 'user');-- assert.strictEqual(bySlug('blue-lake-22').owner_subject, A.subject_id, 'pre-cutoff network id');-- assert.strictEqual(bySlug('blue-lake-22').visibility, 'unlisted');-- assert.strictEqual(bySlug('cold-ridge-33').owner_subject, null, 'ambiguous');-- assert.strictEqual(bySlug('cold-ridge-33').legacy_user_id, 9);-- assert.strictEqual(bySlug('dark-owl-44').owner_subject, null, 'unmapped');-- assert.strictEqual(bySlug('gold-gem-88').owner_subject, null);-- assert.strictEqual(bySlug('gray-dove-99').owner_subject, null, 'anonymous is not a hold');-- assert.strictEqual(bySlug('fork-of-one-66').forked_from, one.id, 'forked_from remapped to the Community id');-- });---- await check('AI moment pastes: origin ai, no owner, stream in stream_ref, screenshot link kept, no hold', async () => {-- const ai = bySlug('wild-moment-5555');-- assert.strictEqual(ai.origin, 'ai');-- assert.strictEqual(ai.owner_subject, null);-- assert.strictEqual(ai.legacy_user_id, 109);-- assert.deepStrictEqual(JSON.parse(ai.stream_ref), { service: 'live', type: 'stream', id: '77' });-- assert.strictEqual(ai.screenshot_url, 'https://openvibe.media/p/wild-moment-5555/screenshot');-- assert.strictEqual(ai.media_ref, 'legacy:live:paste:5');-- const byName = bySlug('epic-frame-7777');-- assert.strictEqual(byName.origin, 'ai', 'recognised by the AI frame file name Media kept');-- assert.deepStrictEqual(JSON.parse(byName.stream_ref), { service: 'live', type: 'stream', id: '12' });-- assert.ok(!holds().some((h) => h.source_id === '5' || h.source_id === '7'));-- });---- await check('holds: ambiguous / unmapped owners, unmappable likes and orphan comments', async () => {-- assert.deepStrictEqual(holds(), [-- { source_type: 'comment', source_id: '13', reason: 'owner_unmapped' },-- { source_type: 'comment', source_id: '14', reason: 'paste_missing' },-- { source_type: 'like', source_id: '1:555', reason: 'owner_unmapped' },-- { source_type: 'like', source_id: '1:9', reason: 'ambiguous_owner' },-- { source_type: 'paste', source_id: '3', reason: 'ambiguous_owner' },-- { source_type: 'paste', source_id: '4', reason: 'owner_unmapped' },-- { source_type: 'paste', source_id: '8', reason: 'owner_unmapped' },-- ]);-- const detail = JSON.parse(db.prepare("SELECT detail FROM import_hold WHERE source_type = 'paste' AND source_id = '3'").get().detail);-- assert.strictEqual(detail.live_subject, C.subject_id);-- assert.strictEqual(detail.network_subject, B.subject_id);-- assert.deepStrictEqual(first.holds, { ambiguous_owner: 2, owner_unmapped: 4, paste_missing: 1 });-- });---- await check('comments and likes: authors mapped, reply parents remapped, only mappable likes imported', async () => {-- const cs = db.prepare('SELECT * FROM paste_comments ORDER BY legacy_media_id').all();-- assert.strictEqual(cs.length, 3);-- const [c11, c12, c13] = cs;-- assert.strictEqual(c11.author_subject, A.subject_id);-- assert.strictEqual(c12.anon_name, 'Visitor');-- assert.strictEqual(c12.parent_id, c11.id);-- assert.strictEqual(c13.author_subject, null);-- assert.deepStrictEqual(first.comments, { bundle: 4, inserted: 3, updated: 0, unchanged: 0, held: 1, skipped: 1 });-- const ls = db.prepare('SELECT subject_id FROM paste_likes').all();-- assert.deepStrictEqual(ls, [{ subject_id: A.subject_id }]);-- assert.deepStrictEqual(first.likes, { bundle: 3, inserted: 1, updated: 0, unchanged: 0, held: 2, skipped: 0 });-- });---- await check('mappings and the run are recorded', async () => {-- const m = (sys, type, id) => db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?').get(sys, type, id);-- assert.deepStrictEqual(m('media', 'paste', '1'), { target_type: 'paste', target_id: String(bySlug('amber-fox-11').id) });-- assert.deepStrictEqual(m('live', 'user', '107'), { target_type: 'subject', target_id: A.subject_id });-- assert.deepStrictEqual(m('network', 'user', '7'), { target_type: 'subject', target_id: A.subject_id });-- assert.ok(m('media', 'comment', '12'));-- const runs = db.prepare('SELECT * FROM migration_runs').all();-- assert.strictEqual(runs.length, 1);-- assert.strictEqual(JSON.parse(runs[0].report).pastes.inserted, 9);-- assert.ok(runs[0].finished_at);-- });---- await check('re-running the same bundle changes nothing', async () => {-- const before = { pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') };-- const again = await run(bundle);-- assert.deepStrictEqual([again.pastes.inserted, again.pastes.updated, again.pastes.unchanged], [0, 0, 9]);-- assert.deepStrictEqual([again.comments.inserted, again.comments.updated, again.comments.unchanged], [0, 0, 3]);-- assert.deepStrictEqual([again.likes.inserted, again.likes.unchanged, again.likes.held], [0, 1, 2]);-- assert.deepStrictEqual({ pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') }, before);-- assert.strictEqual(count('migration_runs'), 2);-- });---- await check('a newer bundle adds and updates; Community-side edits, counts and deletes are not undone; late mappings clear holds', async () => {-- // Community-side activity since the first import.-- db.prepare("UPDATE pastes SET views = 80 WHERE slug = 'amber-fox-11'").run();-- const store = require('../server/pastes/store');-- store.updatePaste(db, bySlug('blue-lake-22').id, { content: 'edited in Community' }, A.subject_id);-- store.softDelete(db, bySlug('gray-dove-99').id);-- // Someone registered live user 555 with the Network in the meantime.-- const D = net.addUser({ network_user_id: 40, username: 'dana' });-- net.directory.legacy.live[555] = D.subject_id;---- const newer = seal({-- pastes: pastes.map((p) => (p.id === 1 ? { ...p, title: 'One (renamed on Live)', views: 60, updated_at: '2026-09-10 00:00:00' }-- : p.id === 2 ? { ...p, content: 'changed on Live' } : p))-- .concat([paste({ id: 10, slug: 'new-wave-10', user_id: 109, created_at: '2026-09-20 00:00:00', updated_at: '2026-09-20 00:00:00' })]),-- comments: comments.concat([{ id: 15, paste_id: 10, user_id: 109, parent_id: null, anon_name: null, message: 'fresh', is_deleted: 0, created_at: POST, updated_at: POST }]),-- likes,-- });-- const r = await run(newer);-- assert.strictEqual(r.pastes.inserted, 1);-- assert.strictEqual(r.comments.inserted, 1);-- const one = bySlug('amber-fox-11');-- assert.strictEqual(one.title, 'One (renamed on Live)');-- assert.strictEqual(one.views, 80, 'counters never go down');-- assert.strictEqual(bySlug('blue-lake-22').content, 'edited in Community', 'local edits win');-- assert.ok(bySlug('gray-dove-99').deleted_at, 'deleted stays deleted');-- assert.strictEqual(bySlug('new-wave-10').owner_subject, B.subject_id);-- assert.strictEqual(bySlug('dark-owl-44').owner_subject, D.subject_id, 'late mapping fills the owner');-- assert.ok(!holds().some((h) => h.source_type === 'paste' && h.source_id === '4'), 'and clears its hold');-- assert.ok(!holds().some((h) => h.source_type === 'like' && h.source_id === '1:555'));-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS c FROM paste_likes').get().c, 2);-- assert.strictEqual(db.prepare("SELECT author_subject FROM paste_comments WHERE legacy_media_id = 13").get().author_subject, D.subject_id);-- assert.strictEqual(r.pastes.updated, 2, 'the renamed paste and the newly owned one');-- });---- await check('rows from before the OpenVibe launch map by Live id even when the number is also a Network id', async () => {-- const mdb = openDb(':memory:');-- const r = await importBundle(mdb, seal({ pastes: [paste({ id: 70, slug: 'old-hobo-70', user_id: 9, created_at: MIGRATED, updated_at: MIGRATED })], comments: [], likes: [] }), { resolveLegacy: identity.resolveLegacy, source: 'test' });-- const row = mdb.prepare('SELECT owner_subject FROM pastes WHERE slug = ?').get('old-hobo-70');-- assert.strictEqual(row.owner_subject, C.subject_id, 'live 9 = cleo, not network 9 = sam');-- assert.strictEqual(r.pastes.held, 0);-- const r2 = await importBundle(openDb(':memory:'), seal({ pastes: [paste({ id: 70, slug: 'old-hobo-70', user_id: 9, created_at: MIGRATED, updated_at: MIGRATED })], comments: [], likes: [] }), { resolveLegacy: identity.resolveLegacy, source: 'test', ambiguousFrom: null });-- assert.strictEqual(r2.pastes.held, 1, 'ambiguousFrom: null restores the whole-history window');-- mdb.close();-- });---- await check('CLI: exit 2 on a refused bundle, dry run prints the report and writes nothing', async () => {-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-import-'));-- const dbPath = path.join(dir, 'c.db');-- const good = path.join(dir, 'good.json');-- const bad = path.join(dir, 'bad.json');-- fs.writeFileSync(good, JSON.stringify(bundle));-- fs.writeFileSync(bad, JSON.stringify({ ...bundle, sha256: 'f'.repeat(64) }));-- const env = { ...process.env, COMMUNITY_DB_PATH: dbPath, OV_NETWORK_INTERNAL_URL: net.url, OV_NETWORK_URL: net.url, OV_OAUTH_CLIENT_ID: 'community', OV_OAUTH_CLIENT_SECRET: 'shh' };-- const script = path.join(__dirname, '..', 'scripts', 'import-pastes.js');-- const refused = spawnSync(process.execPath, [script, bad], { env, encoding: 'utf8' });-- assert.strictEqual(refused.status, 2, refused.stderr);-- assert.match(refused.stderr, /Refused: sha256 mismatch/);-- // Run the child asynchronously: the mock Network lives in this process's event loop.-- const { spawn } = require('child_process');-- const out = await new Promise((resolve) => {-- const child = spawn(process.execPath, [script, good, '--dry-run', '--id-fix-cutoff', '2026-08-20T03:00:26Z'], { env });-- let stdout = '', stderr = '';-- child.stdout.on('data', (c) => { stdout += c; });-- child.stderr.on('data', (c) => { stderr += c; });-- child.on('close', (code) => resolve({ code, stdout, stderr }));-- });-- assert.strictEqual(out.code, 0, out.stderr);-- assert.match(out.stdout, /DRY RUN — rolled back/);-- // Live user 555 is mapped by now (previous check), so two pastes are held, not three.-- assert.match(out.stdout, /pastes\s+9\s+9\s+0\s+0\s+2\s+0/, out.stdout);-- const check2 = openDb(dbPath);-- assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM pastes').get().c, 0);-- assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM subject_projection').get().c, 0, 'not even the display cache');-- check2.close();-- fs.rmSync(dir, { recursive: true, force: true });-- });---- db.close();-- await net.close();-- done();--})();@@@ -181,7 +181,7 @@ function startVip() {await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {const pulse = (await call('/api/v1/pulse')).text;assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id)).c, 0);++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);const sitemap = (await call('/sitemap.xml')).text;assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');const feed = (await call('/s/feed.xml')).text;@@@ -195,7 +195,7 @@ function startVip() {await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });assert.ok(!(await call('/api/v1/pulse')).text.includes('Soon private'));// Pulse's read-time check alone hides an item nothing removed.-- t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));++ await t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));assert.ok(!(await call('/api/v1/pulse')).text.includes('stale gated item'));});ddeleted file mode 100644@@@ -1,50 +0,0 @@--'use strict';--/**-- * scripts/migrate-screenshot-refs.js (C-24): a dry run changes nothing; --apply needs --backup and takes it-- * first; imported pastes (legacy:live:paste:<Live id>) are matched by slug, v1 uploads by their reference;-- * deleted objects and pastes with no object keep their legacy reference; running it again changes nothing.-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { execFileSync } = require('child_process');--const { openDb } = require('../server/db');----const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-c24-'));--const dbPath = path.join(dir, 'community.db');--const db = openDb(dbPath);--const ins = db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url, media_ref) VALUES (?, 'screenshot', 'Shot', ?, ?)");--ins.run('a1', 'https://openvibe.media/p/a1/screenshot', 'legacy:live:paste:2');--ins.run('a2', 'https://openvibe.media/p/a2/screenshot', 'legacy:live:paste:4');--ins.run('a3', 'https://openvibe.media/p/a3/screenshot', 'legacy:live:paste:9');--ins.run('k1', 'https://openvibe.media/f/k1', 'legacy:community:file:k1');--db.prepare("INSERT INTO pastes (slug, type, title, content) VALUES ('t1', 'paste', 'Text', 'x')").run();--db.close();--const M1 = 'med_01KKDDF5T0R3JNZSTEBW0X4AH5', M2 = 'med_01KKDDZWZ0SG9ZQ3ABAKDDSGWG', M3 = 'med_01KKDMS07GK1BS5KWRXWC6NAV8';--const map = path.join(dir, 'refs.json');--fs.writeFileSync(map, JSON.stringify({ objects: [-- { legacy_ref: 'legacy:live:paste:a1', id: M1, lifecycle_status: 'ready' },-- { legacy_ref: 'legacy:live:paste:a2', id: M2, lifecycle_status: 'deleted' },-- { legacy_ref: 'legacy:community:file:k1', id: M3, lifecycle_status: 'ready' },--] }));--const SCRIPT = path.join(__dirname, '..', 'scripts', 'migrate-screenshot-refs.js');--const run = (...extra) => {-- try { return { code: 0, out: JSON.parse(execFileSync(process.execPath, [SCRIPT, '--db', dbPath, '--map', map, ...extra], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })) }; } catch (err) { return { code: err.status, out: null }; }--};--const refs = () => { const d = openDb(dbPath); const r = Object.fromEntries(d.prepare('SELECT slug, media_ref FROM pastes ORDER BY id').all().map((x) => [x.slug, x.media_ref])); d.close(); return r; };----let r = run();--assert.strictEqual(r.code, 0);--assert.deepStrictEqual([r.out.candidates, r.out.would_migrate, r.out.no_object, r.out.not_ready, r.out.migrated], [4, 2, 1, 1, 0]);--assert.strictEqual(refs().a1, 'legacy:live:paste:2', 'a dry run changes nothing');--assert.strictEqual(run('--apply').code, 2, '--apply without --backup is refused');--const backup = path.join(dir, 'before.db');--r = run('--apply', '--backup', backup);--assert.strictEqual(r.code, 0); assert.strictEqual(r.out.migrated, 2);--assert.ok(fs.existsSync(backup) && fs.statSync(backup).size > 0, 'the backup was taken');--assert.deepStrictEqual(refs(), { a1: M1, a2: 'legacy:live:paste:4', a3: 'legacy:live:paste:9', k1: M3, t1: null });--r = run('--apply', '--backup', path.join(dir, 'again.db'));--assert.deepStrictEqual([r.out.candidates, r.out.migrated], [2, 0], 'idempotent');--fs.rmSync(dir, { recursive: true, force: true });--console.log('migrate-screenshot-refs: all checks passed');@@@ -77,7 +77,24 @@ async function check(name, fn) {fs.mkdirSync(path.dirname(dbPath), { recursive: true });fs.mkdirSync(dataDir, { recursive: true });let server = null;++ // This release on PostgreSQL (ADR-035) and N-1 on SQLite: they never share a database (the switch stops the++ // service, imports, then starts this release), so N-1's SQL has no schema of this release to run on. The client++ // half still runs, against a fresh migrated database.++ const pg = !!require('../server/db').initDb;++ const sqlSkip = pg && worker.engine !== 'postgresql' ? 'N-1 SQL: skipped (N-1 runs on SQLite and this release on PostgreSQL: no shared database, so no N-1 statement runs on this schema; scripts/migrate-to-postgres.js moves the data and was rehearsed)' : null;++ // Both on PostgreSQL: N keeps every migration N-1 ran, byte for byte, and only adds (expand first, ADR-028).++ if (pg && worker.engine === 'postgresql') {++ await check(`N-1's ${worker.migrations.length} migration file(s) are all here, unchanged`, () => {++ const dir = path.join(ROOT, 'migrations');++ const hash = (f) => require('crypto').createHash('sha256').update(fs.readFileSync(path.join(dir, f))).digest('hex');++ const bad = worker.migrations.filter((m) => !fs.existsSync(path.join(dir, m.name)) || hash(m.name) !== m.sha256).map((m) => m.name);++ assert.deepStrictEqual(bad, [], 'a migration N-1 ran was removed or edited: write a new one instead');++ });++ }try {++ if (pg) {++ server = await svc.boot({ dir: ROOT, dbPath, dataDir });++ } else {console.log(`n-1: this release on a database N-1 created`);await check('N-1\'s schema, ledger and first-use tables load, and this release migrates and seeds it', () => {const d = new Database(dbPath);@@@ -96,6 +113,7 @@ async function check(name, fn) {svc.seed({ dir: ROOT, dbPath, dataDir });});server = await svc.boot({ dir: ROOT, dbPath, dataDir });++ }const manifest = await (await fetch(`${server.url}/release.json`)).json();console.log('n-1: the N-1 client against this server');@@@ -124,6 +142,8 @@ async function check(name, fn) {await server.close();server = null;++ if (sqlSkip) console.log(sqlSkip);++ else if (!pg) {console.log('n-1: N-1\'s SQL on the schema this release migrated');await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, () => {const d = new Database(dbPath, { readonly: true });@@@ -131,6 +151,7 @@ async function check(name, fn) {d.close();assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');});++ }} finally {if (server) { console.log(server.log().slice(-1500)); await server.close(); }fs.rmSync(tmp, { recursive: true, force: true });@@@ -35,7 +35,13 @@ const path = require('path');COMMUNITY_DB_PATH: process.env.N1_DB,});const { createApp } = require(path.join(dir, 'server', 'app'));-- const app = createApp({++ // A release on PostgreSQL (ADR-035; server/db.js initDb) gets a migrated test database of its own; a SQLite++ // release opens COMMUNITY_DB_PATH itself.++ const dbMod = require(path.join(dir, 'server', 'db'));++ const pgDb = dbMod.initDb ? await require(path.join(dir, 'test', 'helpers', 'db')).testDb() : null;++ if (pgDb) dbMod.setDb(pgDb);++ const app = await createApp({++ ...(pgDb ? { db: pgDb, valkey: null } : {}),pasteLimits: { cooldownSeconds: 0 },forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 },commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },@@@ -58,6 +64,6 @@ const path = require('path');const commentThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref } })).thread;if (commentThread) await call(`/api/v1/comments/threads/${commentThread.id}/comments`, { method: 'POST', json: { body: 'A comment from N-1' } });-- process.on('SIGTERM', () => { server.close(); try { require(path.join(dir, 'server', 'db')).closeDb(); } catch { /* */ } process.exit(0); });++ process.on('SIGTERM', async () => { server.close(); try { if (pgDb) await pgDb.close(); else require(path.join(dir, 'server', 'db')).closeDb(); } catch { /* */ } process.exit(0); });process.stdout.write(`${JSON.stringify({ n1: { url, token, ids: { thread: thread && thread.id, thread_slug: thread && thread.slug, paste: paste && paste.slug, comment_thread: commentThread && commentThread.id, comment_access: commentThread && (commentThread.access_id || commentThread.id) } } })}\n`);})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });@@@ -23,12 +23,12 @@ function raw(base, path, headers = {}) {assert.strictEqual(b.status, 'ready');assert.strictEqual(b.service, 'community');assert.strictEqual(b.pastes_authority, 'live');-- assert.deepStrictEqual(Object.keys(b.checks), ['db', 'network_jwks', 'live']);++ assert.deepStrictEqual(Object.keys(b.checks), ['db', 'valkey', 'network_jwks', 'live']);assert.strictEqual(b.checks.db.required, true);assert.strictEqual(b.checks.network_jwks.required, false);assert.strictEqual(b.checks.live.required, false);-- for (const c of Object.values(b.checks)) {-- assert.strictEqual(c.status, 'ok');++ for (const [name, c] of Object.entries(b.checks)) {++ assert.strictEqual(c.status, name === 'valkey' && !process.env.VALKEY_URL ? 'skipped' : 'ok', name); // Valkey under test:pg onlyassert.strictEqual(typeof c.latency_ms, 'number');assert.ok(Date.parse(c.checked_at));}@@@ -189,7 +189,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(p.avatar_url, `${net.url}/avatar/kim`, 'relative Network avatars made absolute');assert.ok(net.resolveCalls.slice(before).some((b) => Array.isArray(b.subject_ids) && b.subject_ids.includes(kim.subject_id)));kim.display_name = 'Kim Renamed';-- t.db.prepare("UPDATE subject_projection SET refreshed_at = datetime('now', '-1 day') WHERE subject_id = ?").run(kim.subject_id);++ await t.db.prepare("UPDATE subject_projection SET refreshed_at = datetime('now', '-1 day') WHERE subject_id = ?").run(kim.subject_id);const stale = await call(`/api/pastes/${p.slug}?no_view=1`);assert.strictEqual(stale.json().paste.display_name, 'Kim', 'stale entry served while it refreshes');await new Promise((res) => setTimeout(res, 100));@@@ -256,7 +256,7 @@ const { boot, check, done } = require('./helpers/app');const meta = JSON.parse(p.metadata);assert.strictEqual(meta.page_url, 'https://example.com/x');assert.strictEqual(meta.original_name, 'desk.jpg');-- const row = t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);++ const row = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);assert.match(row.media_ref, /^med_[0-9A-HJKMNP-TV-Z]{26}$/); assert.strictEqual(row.media_ref, up.key);shotSlug = p.slug;const viaCreate = new FormData();@@@ -7,35 +7,36 @@*/const assert = require('assert');const { ids } = require('openvibe-contracts');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const store = require('../server/pastes/store');const { createPasteService } = require('../server/pastes/service');const { check, done } = require('./helpers/app');--const db = openDb(':memory:');--const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });--const alice = ids.newId('user');--const viewer = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };--const daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);++let db, svc, alice, viewer, daysAgo, add, slugs;--const add = (slug, { origin = 'user', visibility = 'public', views = 0, likes = 0, pinned = 0, age = 1, burn = 0, iso = false } = {}) => {-- const row = store.insertPaste(db, { slug, owner_subject: origin === 'ai' ? null : alice, origin, type: 'paste', title: slug, content: slug, language: 'text', visibility, pinned, burn_after_read: burn });-- const at = iso ? new Date(Date.now() - age * 86400_000).toISOString() : daysAgo(age);-- db.prepare('UPDATE pastes SET views = ?, likes = ?, created_at = ? WHERE id = ?').run(views, likes, at, row.id);--};--add('person-new', { age: 1, views: 4 });--add('person-liked', { age: 2, views: 1, likes: 3 }); // score 16--add('person-pinned-old', { age: 30, views: 2, pinned: 1 });--add('person-private', { age: 1, views: 99, visibility: 'private' });--add('person-unlisted', { age: 1, views: 99, visibility: 'unlisted' });--add('person-burn', { age: 1, views: 99, burn: 1 });--add('person-imported-iso', { age: 3, views: 5, iso: true });--add('ai-moment', { origin: 'ai', age: 1, views: 7 });--add('ai-old', { origin: 'ai', age: 20, views: 50 });----const slugs = async (q) => (await svc.list(viewer, q)).pastes.map((p) => p.slug);++ // score 16(async () => {++ db = await testDb();++ svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });++ alice = ids.newId('user');++ viewer = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };++ daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);++ add = async (slug, { origin = 'user', visibility = 'public', views = 0, likes = 0, pinned = 0, age = 1, burn = 0, iso = false } = {}) => {++ const row = await store.insertPaste(db, { slug, owner_subject: origin === 'ai' ? null : alice, origin, type: 'paste', title: slug, content: slug, language: 'text', visibility, pinned, burn_after_read: burn });++ const at = iso ? new Date(Date.now() - age * 86400_000).toISOString() : daysAgo(age);++ await db.prepare('UPDATE pastes SET views = ?, likes = ?, created_at = ? WHERE id = ?').run(views, likes, at, row.id);++ };++ await add('person-new', { age: 1, views: 4 });++ await add('person-liked', { age: 2, views: 1, likes: 3 });++ await add('person-pinned-old', { age: 30, views: 2, pinned: 1 });++ await add('person-private', { age: 1, views: 99, visibility: 'private' });++ await add('person-unlisted', { age: 1, views: 99, visibility: 'unlisted' });++ await add('person-burn', { age: 1, views: 99, burn: 1 });++ await add('person-imported-iso', { age: 3, views: 5, iso: true });++ await add('ai-moment', { origin: 'ai', age: 1, views: 7 });++ await add('ai-old', { origin: 'ai', age: 20, views: 50 });++ slugs = async (q) => (await svc.list(viewer, q)).pastes.map((p) => p.slug);await check('origin=user and origin=ai split the public list; hidden pastes are in neither', async () => {const people = await slugs({ origin: 'user', pinned_first: '0' });assert.deepStrictEqual(people, ['person-new', 'person-liked', 'person-imported-iso', 'person-pinned-old']);@@@ -6,33 +6,32 @@const assert = require('assert');const http = require('http');const { modules } = require('openvibe-contracts');--const { openDb } = require('../server/db');--const { summarize, createProfileModule, ensureSchema } = require('../server/identity/profile-module');++const { testDb } = require('./helpers/db');++const { summarize, createProfileModule } = require('../server/identity/profile-module');--const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';--const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';--const db = openDb(':memory:');--ensureSchema(db);--const now = Date.parse('2026-09-25T12:00:00Z');--const at = (msAgo) => new Date(now - msAgo).toISOString().replace('T', ' ').slice(0, 19);--const DAY = 86400000;----const space = db.prepare("INSERT INTO spaces (slug, name) VALUES ('profile-test', 'Profile test')").run().lastInsertRowid;--const thread = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, created_at) VALUES (?, ?, ?, ?, ?)').run(space, 'hello', 'Hello', ANN, at(10 * DAY)).lastInsertRowid;--db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 1, ?, ?)').run(thread, ANN, 'hi', at(10 * DAY));--db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 0, ?, ?)').run(thread, ANN, 'reply', at(2 * DAY));--db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at, deleted_at) VALUES (?, ?, 0, ?, ?, ?)').run(thread, ANN, 'gone', at(DAY), at(DAY));--const ct = db.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', '1')").run().lastInsertRowid;--db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, ANN, 'nice', at(3 * DAY));--db.prepare("INSERT INTO comments (thread_id, author_subject, origin, message, created_at) VALUES (?, ?, 'ai', ?, ?)").run(ct, ANN, 'bot', at(3 * DAY));--db.prepare("INSERT INTO pastes (slug, owner_subject, content, created_at) VALUES ('p1', ?, 'x', ?)").run(ANN, at(5 * DAY));--db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, created_at) VALUES ('p2', ?, 'x', 'private', ?)").run(ANN, at(5 * DAY));++let ANN, BOB, db, now, at, DAY, space, thread, ct;(async () => {-- const s = summarize(db, ANN);++ ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';++ BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';++ db = await testDb();++ now = Date.parse('2026-09-25T12:00:00Z');++ at = (msAgo) => new Date(now - msAgo).toISOString().replace('T', ' ').slice(0, 19);++ DAY = 86400000;++ space = (await db.prepare("INSERT INTO spaces (slug, name) VALUES ('profile-test', 'Profile test') RETURNING id").run()).lastInsertRowid;++ thread = (await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id').run(space, 'hello', 'Hello', ANN, at(10 * DAY))).lastInsertRowid;++ await db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 1, ?, ?)').run(thread, ANN, 'hi', at(10 * DAY));++ await db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 0, ?, ?)').run(thread, ANN, 'reply', at(2 * DAY));++ await db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at, deleted_at) VALUES (?, ?, 0, ?, ?, ?)').run(thread, ANN, 'gone', at(DAY), at(DAY));++ ct = (await db.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', '1') RETURNING id").run()).lastInsertRowid;++ await db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, ANN, 'nice', at(3 * DAY));++ await db.prepare("INSERT INTO comments (thread_id, author_subject, origin, message, created_at) VALUES (?, ?, 'ai', ?, ?)").run(ct, ANN, 'bot', at(3 * DAY));++ await db.prepare("INSERT INTO pastes (slug, owner_subject, content, created_at) VALUES ('p1', ?, 'x', ?)").run(ANN, at(5 * DAY));++ await db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, created_at) VALUES ('p2', ?, 'x', 'private', ?)").run(ANN, at(5 * DAY));++ const s = await summarize(db, ANN);assert.deepStrictEqual(s, { threads: 1, posts: 1, comments: 1, pastes: 1, first_active_at: new Date(now - 10 * DAY).toISOString(), last_active_at: new Date(now - 2 * DAY).toISOString() });assert.ok(modules.validateData('community.profile', s).valid, 'matches the namespace schema');-- assert.strictEqual(summarize(db, BOB), null, 'nothing written: no record');++ assert.strictEqual(await summarize(db, BOB), null, 'nothing written: no record');const puts = [];const server = http.createServer((req, res) => {@@@ -56,8 +55,8 @@ db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, createassert.strictEqual(await mod.push('gst_01JAB2C3D4E5F6G7H8J9K0MNPG'), false, 'guests have no record');// The scan: Bob comments, Ann deletes her reply.-- db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, BOB, 'first!', at(60000));-- db.prepare("UPDATE posts SET deleted_at = ? WHERE body_markdown = 'reply'").run(at(30000));++ await db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, BOB, 'first!', at(60000));++ await db.prepare("UPDATE posts SET deleted_at = ? WHERE body_markdown = 'reply'").run(at(30000));assert.strictEqual(await mod.scan({ now }), 2);assert.deepStrictEqual(puts.slice(1).map((p) => [p.subject, p.data.comments, p.data.posts]).sort(), [[ANN, 1, 0], [BOB, 1, 0]]);assert.strictEqual(await mod.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');@@@ -70,12 +70,14 @@ const { boot, check, done } = require('./helpers/app');await check('an unreachable Live answers 502 JSON instead of hanging', async () => {const { createApp } = require('../server/app');const http = require('http');-- const app = createApp({ liveUrl: 'http://127.0.0.1:1' });++ const db = await require('./helpers/db').testDb();++ const app = await createApp({ liveUrl: 'http://127.0.0.1:1', db });const srv = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/pastes`);assert.strictEqual(res.status, 502);assert.deepStrictEqual(await res.json(), { error: 'Could not reach the paste service' });await new Promise((r) => srv.close(r));++ await db.close();});await t.close();@@@ -4,36 +4,37 @@ const assert = require('assert');const http = require('http');const express = require('express');const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const { createPulseConsumer } = require('../server/pulse/consumer');--const SECRET = `whsec_${'ab'.repeat(32)}`;--const db = openDb(':memory:');--const app = express();--const vipDrops = [];--app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], vipCache: { handleEvent: (e) => { vipDrops.push(e.event_id); return true; } }, log: { error() {}, log() {}, warn() {} } }).router);--const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';--let seq = 0;--const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).padStart(3, '0')}`.slice(0, 30), event_type: 'live.stream.started', version: 1, source: 'live', visibility: 'public', timestamp: new Date().toISOString(),-- subject: { type: 'stream', id: '2477', revision: 1 }, actor: { type: 'user', id: USR },-- payload: { stream_id: 2477, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: USR } }, title: 'Night walk', started_at: new Date().toISOString() }, ...over });++let SECRET, db, app, vipDrops, USR, seq, evt;(async () => {++ SECRET = `whsec_${'ab'.repeat(32)}`;++ db = await testDb();++ app = express();++ vipDrops = [];++ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], vipCache: { handleEvent: (e) => { vipDrops.push(e.event_id); return true; } }, log: { error() {}, log() {}, warn() {} } }).router);++ USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ seq = 0;++ evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).padStart(3, '0')}`.slice(0, 30), event_type: 'live.stream.started', version: 1, source: 'live', visibility: 'public', timestamp: new Date().toISOString(),++ subject: { type: 'stream', id: '2477', revision: 1 }, actor: { type: 'user', id: USR },++ payload: { stream_id: 2477, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: USR } }, title: 'Night walk', started_at: new Date().toISOString() }, ...over });const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });const post = (event, { secret = SECRET, headers = {} } = {}) => {const body = JSON.stringify({ event, seq: 1 });return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));};-- const items = () => db.prepare('SELECT source_service, source_type, source_id, title, url, actor_subject, origin FROM pulse_items ORDER BY id').all();++ const items = async () => await db.prepare('SELECT source_service, source_type, source_id, title, url, actor_subject, origin FROM pulse_items ORDER BY id').all();const e1 = evt();let r = await post(e1);assert.strictEqual(r.status, 200); assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.deepStrictEqual(items(), [{ source_service: 'live', source_type: 'stream', source_id: '2477', title: 'Goosely went live: Night walk', url: 'https://openvibe.live/@goosely', actor_subject: USR, origin: 'user' }]);++ assert.deepStrictEqual(await items(), [{ source_service: 'live', source_type: 'stream', source_id: '2477', title: 'Goosely went live: Night walk', url: 'https://openvibe.live/@goosely', actor_subject: USR, origin: 'user' }]);r = await post(e1);assert.strictEqual(r.json.duplicate, true, 'a redelivery does nothing');-- assert.strictEqual(items().length, 1);++ assert.strictEqual((await items()).length, 1);assert.strictEqual((await post(evt(), { secret: `whsec_${'cd'.repeat(32)}` })).status, 401, 'forged signature');assert.strictEqual((await post(evt(), { headers: { 'x-forwarded-for': '1.2.3.4' } })).status, 403, 'came through the proxy');@@@ -42,12 +43,12 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).pr = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },payload: { blog: 'openvibe', canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.strictEqual(items()[1].title, 'New post on the openvibe blog');-- assert.strictEqual(items()[1].origin, 'system', 'no person when a service published it');++ assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');++ assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_2' },payload: { canonical_url: 'https://openvibe.wiki/w/help/start', publication_state: 'published', visibility: 'public', space: 'help', slug: 'start', indexability: { decision: 'noindex', reasons: ['thin'] } } }));assert.strictEqual(r.json.outcome, 'ignored:not_public', 'a noindex page stays out of Pulse');-- assert.strictEqual(items().length, 2);++ assert.strictEqual((await items()).length, 2);// VIP convergence: a membership change from VIP drops cached members-only answers once; from anyone else, nothing.const vipEv = (source) => evt({ event_type: 'vip.membership.changed', source, subject: { type: 'membership', id: 'mbr_1', revision: 2 }, actor: { type: 'service', id: 'vip' },payload: { member: { type: 'user', id: USR }, creator: { type: 'user', id: USR }, status: 'canceled' } });@@@ -59,7 +60,7 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).pr = await post(vipEv('live'));assert.strictEqual(r.json.outcome, 'ignored:source');assert.deepStrictEqual(vipDrops, [v1.event_id]);-- assert.strictEqual(items().length, 2, 'no Pulse item for a membership');++ assert.strictEqual((await items()).length, 2, 'no Pulse item for a membership');// Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },@@@ -71,19 +72,19 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).pconst m11 = mod(11);r = await post(m11);assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.deepStrictEqual(items()[2], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });++ assert.deepStrictEqual((await items())[2], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });assert.strictEqual((await post(m11)).json.duplicate, true);r = await post(mod(6));assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');r = await post(mod(12));assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');r = await post(mod(15));-- assert.strictEqual(items()[3].title, 'Reached level 15 in Scraplandia');++ assert.strictEqual((await items())[3].title, 'Reached level 15 in Scraplandia');assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');-- assert.strictEqual(items().length, 4);++ assert.strictEqual((await items()).length, 4);srv.close();console.log('pulse consumer: all checks passed');process.exit(0);@@@ -21,8 +21,8 @@ const { boot, check, done } = require('./helpers/app');const forum = t.app.locals.forum;const svc = (cap, extra = {}) => net.signService({ cap, ...extra });const PULSE = 'community.pulse.write';-- const items = () => t.db.prepare('SELECT * FROM pulse_items ORDER BY id').all();-- const bySource = (type, id) => t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = ? AND source_id = ?").get(type, String(id));++ const items = async () => await t.db.prepare('SELECT * FROM pulse_items ORDER BY id').all();++ const bySource = async (type, id) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = ? AND source_id = ?").get(type, String(id));const call = (path, { method = 'GET', token, headers = {}, json } = {}) => {const h = { ...headers };if (token) h.authorization = `Bearer ${token}`;@@@ -38,11 +38,11 @@ const { boot, check, done } = require('./helpers/app');const burn = (await pastes.createText(asAlex, { content: 'b', burn_after_read: true })).slug;const nsfw = (await pastes.createText(asAlex, { content: 'n', is_nsfw: true })).slug;const ai = (await pastes.createText({ kind: 'service', subject: null, origin: 'ai', claims: { cap: [] } }, { content: 'a' })).slug;-- const it = bySource('paste', pub);++ const it = await bySource('paste', pub);assert.ok(it, 'public paste recorded');assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Public one', `https://openvibe.community/p/${pub}`, alex.subject_id, 'user', 'public']);-- assert.strictEqual(bySource('paste', anonPub).actor_subject, null);-- for (const s of [unl, priv, burn, nsfw, ai]) assert.strictEqual(bySource('paste', s), undefined, `${s} must not enter Pulse`);++ assert.strictEqual((await bySource('paste', anonPub)).actor_subject, null);++ for (const s of [unl, priv, burn, nsfw, ai]) assert.strictEqual(await bySource('paste', s), undefined, `${s} must not enter Pulse`);});await check('pastes leave Pulse when made private or deleted; read-time guard covers what the hooks miss', async () => {@@@ -50,29 +50,29 @@ const { boot, check, done } = require('./helpers/app');const b = (await pastes.createText(asAlex, { title: 'Soon deleted', content: '2' })).slug;const c = (await pastes.createText(asAlex, { title: 'Changed behind our back', content: '3' })).slug;await pastes.update(asAlex, a, { visibility: 'private' });-- pastes.remove(asAlex, b);-- assert.strictEqual(bySource('paste', a), undefined);-- assert.strictEqual(bySource('paste', b), undefined);-- t.db.prepare("UPDATE pastes SET visibility = 'unlisted' WHERE slug = ?").run(c); // no hook ran-- assert.ok(bySource('paste', c), 'row still there');++ await pastes.remove(asAlex, b);++ assert.strictEqual(await bySource('paste', a), undefined);++ assert.strictEqual(await bySource('paste', b), undefined);++ await t.db.prepare("UPDATE pastes SET visibility = 'unlisted' WHERE slug = ?").run(c); // no hook ran++ assert.ok(await bySource('paste', c), 'row still there');const listed = (await call('/api/v1/pulse?limit=100')).json().items.map((i) => i.source.id);assert.ok(!listed.includes(c), 'but never listed');});await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });-- const it = bySource('thread', thread.id);++ const it = await bySource('thread', thread.id);assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/s/general/t/pulse-thread', alex.subject_id, 'user']);const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });-- assert.strictEqual(bySource('post', post.id).title, 'Re: Pulse thread');-- assert.strictEqual(bySource('post', post.id).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);-- t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();++ assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');++ assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);++ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });-- assert.strictEqual(bySource('thread', hidden.thread.id), undefined);-- forum.deletePost(asAlex, post.id);-- assert.strictEqual(bySource('post', post.id), undefined);-- forum.deleteThread(asAlex, 'general', 'pulse-thread');-- assert.strictEqual(bySource('thread', thread.id), undefined);++ assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);++ await forum.deletePost(asAlex, post.id);++ assert.strictEqual(await bySource('post', post.id), undefined);++ await forum.deleteThread(asAlex, 'general', 'pulse-thread');++ assert.strictEqual(await bySource('thread', thread.id), undefined);});await check('POST /items: services with community.pulse.write only; browsers and other caps refused', async () => {@@@ -109,7 +109,7 @@ const { boot, check, done } = require('./helpers/app');const it = again.json().item;assert.strictEqual(it.title, 'A VOD (renamed)');assert.deepStrictEqual([it.origin, it.actor && it.actor.subject, it.occurred_at], ['user', alex.subject_id, '2026-09-20T10:00:00.000Z'], 'origin, actor and time are the first record\'s');-- assert.strictEqual(items().filter((i) => i.source_id === 'v9').length, 1);++ assert.strictEqual((await items()).filter((i) => i.source_id === 'v9').length, 1);});await check('AI items: labelled AI, never attributed (X-OV-Origin: ai or origin ai), system items name no one', async () => {@@@ -118,7 +118,7 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual([viaHeader.json().item.origin, viaHeader.json().item.actor, viaHeader.json().item.label], ['ai', null, 'AI']);const viaBody = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm2' }, title: 'Summary', url: 'https://openvibe.live/m/2', origin: 'ai' } });assert.deepStrictEqual([viaBody.json().item.origin, viaBody.json().item.actor], ['ai', null]);-- assert.strictEqual(t.db.prepare("SELECT actor_subject FROM pulse_items WHERE source_id = 'm2'").get().actor_subject, null);++ assert.strictEqual((await t.db.prepare("SELECT actor_subject FROM pulse_items WHERE source_id = 'm2'").get()).actor_subject, null);const sys = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'event', id: 'e1' }, title: 'Maintenance tonight', url: 'https://openvibe.live/status', origin: 'system' } });assert.deepStrictEqual([sys.json().item.origin, sys.json().item.actor, sys.json().item.label], ['system', null, 'System']);});@@@ -16,7 +16,7 @@ const { createEventsClient } = require('openvibe-sdk/events');const { boot, check, done } = require('./helpers/app');const { startWebhooks } = require('./helpers/fake-discord');const { startEvents } = require('./helpers/fake-events');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const forumStore = require('../server/forum/store');const { createDiscordRelay } = require('../server/relay/discord');const { createRelayEventsWorker, CURSOR } = require('../server/relay/events-worker');@@@ -32,125 +32,125 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.crea(async () => {const hook = await startWebhooks();const events = await startEvents();-- const db = openDb(':memory:');-- const general = forumStore.getSpace(db, 'general');++ const db = await testDb();++ const general = await forumStore.getSpace(db, 'general');const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` };const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env, enabled: true, baseMs: 1000, maxAttempts: 3 });-- relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });++ await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });const client = createEventsClient(createClient({ baseUrls: { events: events.url }, getToken: async () => 'test-token', retries: 0 }), { source: 'community' });const worker = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000, pageLimit: 2 });relay.attach({ worker });const alex = ids.newId('user');-- const newThread = (title, extra = {}) => forumStore.createThread(db, { space_id: general.id, title, author_subject: alex, body_markdown: `${title} body`, ...extra }).thread;-- const deliveries = () => db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();++ const newThread = async (title, extra = {}) => (await forumStore.createThread(db, { space_id: general.id, title, author_subject: alex, body_markdown: `${title} body`, ...extra })).thread;++ const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();const settle = () => new Promise((r) => setImmediate(r));await check('off without Events or the client secret, or with DISCORD_RELAY_EVENTS=off; the forum queues creates then', async () => {const a = createRelayEventsWorker({ db, relay, eventsUrl: null, clientSecret: 'x', tokenUrl: 'http://127.0.0.1:9/oauth/token' });-- assert.deepStrictEqual([a.enabled, a.status().reason], [false, 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set']);++ assert.deepStrictEqual([a.enabled, (await a.status()).reason], [false, 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set']);const b = createRelayEventsWorker({ db, relay, eventsUrl: events.url, clientSecret: '', tokenUrl: 'http://127.0.0.1:9/oauth/token' });assert.strictEqual(b.enabled, false);const c = createRelayEventsWorker({ db, relay, events: client, enabled: false });-- assert.deepStrictEqual([c.enabled, c.status().reason], [false, 'DISCORD_RELAY_EVENTS=off']);++ assert.deepStrictEqual([c.enabled, (await c.status()).reason], [false, 'DISCORD_RELAY_EVENTS=off']);const d = createRelayEventsWorker({ db, relay: createDiscordRelay({ db, env }), events: client });-- assert.match(d.status().reason, /relay is off/);++ assert.match((await d.status()).reason, /relay is off/);c.start();-- assert.strictEqual(relay.status().creates_from, 'forum');++ assert.strictEqual((await relay.status()).creates_from, 'forum');assert.deepStrictEqual(await c.tick(), { queued: 0 });});let before;await check('first start begins at the head: threads from before are never announced', async () => {-- const old1 = newThread('Older one');-- const old2 = newThread('Older two');++ const old1 = await newThread('Older one');++ const old2 = await newThread('Older two');events.add(threadEvent(old1));events.add(threadEvent(old2));events.add(envelope('community.paste.created', { paste_id: 'x' }));relay.start(); // starts the worker too (its interval is an hour here: ticks are driven by the test)await worker.tick();-- assert.strictEqual(relay.status().creates_from, 'events');-- const st = worker.status();++ assert.strictEqual((await relay.status()).creates_from, 'events');++ const st = await worker.status();assert.deepStrictEqual([st.enabled, st.cursor, st.latest_seq, st.started_at_seq, st.lag], [true, 3, 3, 3, 0]);-- assert.strictEqual(deliveries().length, 0);-- assert.deepStrictEqual(db.prepare('SELECT name, cursor FROM relay_cursors').all(), [{ name: CURSOR, cursor: 3 }]);++ assert.strictEqual((await deliveries()).length, 0);++ assert.deepStrictEqual(await db.prepare('SELECT name, cursor FROM relay_cursors').all(), [{ name: CURSOR, cursor: 3 }]);before = events.calls.length;});let t1;let p1;await check('new events queue creates (source events, the event id) that are sent once; the forum\'s own calls queue none', async () => {-- t1 = newThread('From the stream');-- assert.strictEqual(relay.enqueueThread(t1, general), 0, 'the forum queues no creates while the worker runs');-- p1 = forumStore.addPost(db, { thread_id: t1.id, author_subject: alex, body_markdown: 'a reply via events' });-- assert.strictEqual(relay.enqueuePost(p1, t1, general), 0);++ t1 = await newThread('From the stream');++ assert.strictEqual(await relay.enqueueThread(t1, general), 0, 'the forum queues no creates while the worker runs');++ p1 = await forumStore.addPost(db, { thread_id: t1.id, author_subject: alex, body_markdown: 'a reply via events' });++ assert.strictEqual(await relay.enqueuePost(p1, t1, general), 0);const e1 = threadEvent(t1);const e2 = postEvent(p1, t1);events.add(e1);events.add(envelope('community.comment.created', { comment_id: 1 })); // not on the relay's topics: never returnedevents.add(e2);assert.deepStrictEqual(await worker.tick(), { queued: 2 });-- const rows = deliveries();++ const rows = await deliveries();assert.deepStrictEqual(rows.map((d) => [d.action, d.source, d.event_id, d.post_id]), [['create', 'events', e1.event_id, null], ['create', 'events', e2.event_id, p1.id]]);assert.ok(events.calls.slice(before).every((c) => /topic=community\.thread\.\*%2Ccommunity\.post\.\*/.test(c.path) && c.auth === 'Bearer test-token'), JSON.stringify(events.calls.slice(before)));await settle(); await relay.drain(); await relay.drain();assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);assert.match(hook.hits[1].body.content, /replied to \*\*From the stream\*\*/);-- assert.deepStrictEqual([worker.status().cursor, worker.status().lag], [6, 0]);++ assert.deepStrictEqual([(await worker.status()).cursor, (await worker.status()).lag], [6, 0]);});await check('a replay (cursor moved back) queues nothing twice and posts nothing twice', async () => {-- db.prepare('UPDATE relay_cursors SET cursor = 3 WHERE name = ?').run(CURSOR);++ await db.prepare('UPDATE relay_cursors SET cursor = 3 WHERE name = ?').run(CURSOR);assert.deepStrictEqual(await worker.tick(), { queued: 0 });await relay.drain();assert.strictEqual(hook.hits.length, 2);-- assert.strictEqual(worker.status().cursor, 6);++ assert.strictEqual((await worker.status()).cursor, 6);});await check('the cursor survives a restart: a new worker carries on from it (no second "start at head")', async () => {-- const t2 = newThread('While restarting');++ const t2 = await newThread('While restarting');events.add(threadEvent(t2));const again = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000 });assert.deepStrictEqual(await again.tick(), { queued: 1 });-- assert.strictEqual(again.status().started_at_seq, null);-- assert.strictEqual(deliveries().pop().thread_id, t2.id);++ assert.strictEqual((await again.status()).started_at_seq, null);++ assert.strictEqual((await deliveries()).pop().thread_id, t2.id);});await check('members-only, Discord-origin, redacted and foreign events queue nothing', async () => {-- const n = deliveries().length;-- const gated = newThread('Gated', { members_only_owner: alex });-- const fromDiscord = forumStore.addPost(db, { thread_id: t1.id, origin: 'discord', body_markdown: 'hi from discord', relay_author: 'Kim' });-- const t3 = newThread('Looks public');++ const n = (await deliveries()).length;++ const gated = await newThread('Gated', { members_only_owner: alex });++ const fromDiscord = await forumStore.addPost(db, { thread_id: t1.id, origin: 'discord', body_markdown: 'hi from discord', relay_author: 'Kim' });++ const t3 = await newThread('Looks public');events.add(threadEvent(gated, 'members'));events.add(threadEvent(gated)); // even a (wrongly) public-looking event: the row decidesevents.add(postEvent(fromDiscord, t1));events.add({ ...threadEvent(t3), source: 'live' });events.add({ ...threadEvent(t3), payload: { redacted: true } });assert.deepStrictEqual(await worker.tick(), { queued: 0 });-- assert.strictEqual(deliveries().length, n);++ assert.strictEqual((await deliveries()).length, n);});await check('Events down: the error shows, the cursor stays, and it catches up once Events answers', async () => {-- const t4 = newThread('After the outage');++ const t4 = await newThread('After the outage');events.add(threadEvent(t4));-- const cursor = worker.status().cursor;++ const cursor = (await worker.status()).cursor;events.state.failNext = 1;assert.deepStrictEqual(await worker.tick(), { queued: 0 });-- const st = worker.status();++ const st = await worker.status();assert.ok(st.last_error, 'the error is shown');assert.strictEqual(st.cursor, cursor);assert.strictEqual(st.lag, st.latest_seq - cursor);assert.deepStrictEqual(await worker.tick(), { queued: 1 });-- assert.strictEqual(worker.status().last_error, null);++ assert.strictEqual((await worker.status()).last_error, null);});await check('a retention gap is logged and shown to staff; reading carries on after it', async () => {-- db.prepare('UPDATE relay_cursors SET cursor = 1 WHERE name = ?').run(CURSOR);++ await db.prepare('UPDATE relay_cursors SET cursor = 1 WHERE name = ?').run(CURSOR);events.state.prunedThrough = 5;await worker.tick();-- assert.deepStrictEqual([worker.status().last_gap.from_seq, worker.status().last_gap.to_seq], [2, 5]);-- assert.strictEqual(worker.status().cursor, events.log.length);++ assert.deepStrictEqual([(await worker.status()).last_gap.from_seq, (await worker.status()).last_gap.to_seq], [2, 5]);++ assert.strictEqual((await worker.status()).cursor, events.log.length);events.state.prunedThrough = 0;-- assert.strictEqual(relay.status().events_worker.last_gap.to_seq, 5);++ assert.strictEqual((await relay.status()).events_worker.last_gap.to_seq, 5);});await check('the whole path in the app: API write → outbox → Events → worker → Discord, and back out on edit and delete', async () => {@@@ -170,9 +170,9 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.creaconst samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- const waitFor = async (fn, what) => { for (let i = 0; i < 200; i++) { if (fn()) return; await new Promise((r) => setTimeout(r, 20)); } assert.fail(`timed out waiting for ${what}`); };++ const waitFor = async (fn, what) => { for (let i = 0; i < 200; i++) { if (await fn()) return; await new Promise((r) => setTimeout(r, 20)); } assert.fail(`timed out waiting for ${what}`); };assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 201);-- await waitFor(() => t.app.locals.relay.status().events_worker.cursor != null, 'the worker\'s first read');++ await waitFor(async () => (await t.app.locals.relay.status()).events_worker.cursor != null, 'the worker\'s first read');const st = (await call('/api/v1/relay/status', { cookie: adminJwt })).json();assert.deepStrictEqual([st.creates_from, st.events_worker.enabled], ['events', true]);const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Round trip', body: 'through Events' } });@@@ -180,7 +180,7 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.creaawait waitFor(() => hook2.hits.length >= 1, 'the thread on Discord');assert.strictEqual(hook2.hits[0].path, '/api/webhooks/7/app?wait=true');assert.ok(events2.log.some((x) => x.event.event_type === 'community.thread.created'), 'it went through Events');-- const d = t.db.prepare('SELECT * FROM relay_deliveries').get();++ const d = await t.db.prepare('SELECT * FROM relay_deliveries').get();assert.deepStrictEqual([d.source, /^evt_/.test(d.event_id)], ['events', true]);const reply = await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'and a reply' } });await waitFor(() => hook2.hits.length >= 2, 'the reply on Discord');@@@ -15,7 +15,7 @@ const assert = require('assert');const { ids } = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');const { startWebhooks, createGateway } = require('./helpers/fake-discord');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const forumStore = require('../server/forum/store');const { createDiscordRelay } = require('../server/relay/discord');const { createDiscordGateway, DEFAULT_URL, INTENTS } = require('../server/relay/discord-gateway');@@@ -43,7 +43,7 @@ function scriptedGateway() {gw.dispatch = (t, d) => gw.push(gw.last(), { op: 0, t, s: ++gw.seq, d });return gw;}--const waitFor = async (fn, what, ms = 2000) => { const end = Date.now() + ms; while (Date.now() < end) { if (fn()) return; await sleep(5); } assert.fail(`timed out waiting for ${what}`); };++const waitFor = async (fn, what, ms = 2000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return; await sleep(5); } assert.fail(`timed out waiting for ${what}`); };const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op === op);(async () => {@@@ -74,6 +74,7 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==gw.dispatch('MESSAGE_CREATE', { id: '1' });gw.dispatch('GUILD_CREATE', { id: '2' }); // not a message event: dropped heregw.dispatch('MESSAGE_DELETE', { id: '3' });++ await new Promise((r) => setImmediate(r)); // handling is async: events queue in orderassert.deepStrictEqual(seen, [['MESSAGE_CREATE', '1'], ['MESSAGE_DELETE', '3']]);const st = g.status();assert.deepStrictEqual([st.state, st.session, st.bot_user_id, st.events_handled], ['ready', true, BOT, 2]);@@@ -148,10 +149,10 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==// ── inbound: what becomes a post ────────────────────────const hook = await startWebhooks();-- const db = openDb(':memory:');-- const general = forumStore.getSpace(db, 'general');++ const db = await testDb();++ const general = await forumStore.getSpace(db, 'general');const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` }, enabled: true });-- relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true });++ await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true });const CHANNEL = '2000000000000000001'; // the fake webhook 1's channel (learned on the first send)const inbound = createDiscordInbound({ db, perMinute: 3, maxChars: 200 });const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });@@@ -160,38 +161,38 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==const settle = async () => { await new Promise((r) => setImmediate(r)); await relay.drain(); await relay.drain(); };const { thread } = await forum.createThread(samV, 'general', { title: 'Talk to Discord', body: 'Say hi over there' });await settle();-- const threadMsg = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(thread.id).external_message_id;-- const failures = () => db.prepare('SELECT * FROM relay_inbound_failures ORDER BY id').all();++ const threadMsg = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(thread.id)).external_message_id;++ const failures = async () => await db.prepare('SELECT * FROM relay_inbound_failures ORDER BY id').all();const msg = (extra = {}) => ({ id: nextId(), channel_id: CHANNEL, guild_id: '6000000000000000001', type: 19, content: 'hello from Discord', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: threadMsg, channel_id: CHANNEL }, mentions: [], attachments: [], ...extra });-- const postsOf = (tid) => db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id').all(tid);++ const postsOf = async (tid) => await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id').all(tid);await check('a Discord reply to a relayed message becomes a post: origin discord, nobody on the site, the Discord name, mapped', async () => {const m = msg({ content: 'Hi <@4000000000000000003> and <@&4000000000000000009> in <#4000000000000000010> @everyone <:wave:4000000000000000011> <t:1790000000:R>', mentions: [{ id: LEE, username: 'lee', global_name: 'Lee' }], member: { nick: 'Kim (mod)' } });-- const r = inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });++ const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });assert.strictEqual(r.status, 'applied', JSON.stringify(r));-- const p = forumStore.getPost(db, r.post_id);++ const p = await forumStore.getPost(db, r.post_id);assert.deepStrictEqual([p.thread_id, p.origin, p.author_subject, p.relay_author], [thread.id, 'discord', null, 'Kim (mod)']);assert.strictEqual(p.body_markdown, 'Hi @Lee and @role in #channel @\u200beveryone :wave: 2026-09-21 14:13 UTC');-- const row = db.prepare('SELECT * FROM relay_message_map WHERE external_message_id = ?').get(m.id);++ const row = await db.prepare('SELECT * FROM relay_message_map WHERE external_message_id = ?').get(m.id);assert.deepStrictEqual([row.direction, row.local_type, row.local_id, row.thread_id, row.external_channel_id], ['in', 'post', p.id, thread.id, CHANNEL]);const shown = (await forum.getThread(samV, 'general', thread.slug, {})).posts.find((x) => x.id === p.id);assert.deepStrictEqual([shown.author.display_name, shown.author.is_relay, shown.author.subject, shown.origin], ['Kim (mod)', true, null, 'discord']);// The same message again (a gateway replay) is not taken twice.-- assert.deepStrictEqual(inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT }), { status: 'ignored', reason: 'already relayed' });++ assert.deepStrictEqual(await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT }), { status: 'ignored', reason: 'already relayed' });});await check('a message in a Discord thread started from a relayed message becomes a post in that thread', async () => {-- const r = inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, type: 0, message_reference: undefined, content: 'in the Discord thread' }));++ const r = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, type: 0, message_reference: undefined, content: 'in the Discord thread' }));assert.strictEqual(r.status, 'applied');-- assert.strictEqual(forumStore.getPost(db, r.post_id).thread_id, thread.id);++ assert.strictEqual((await forumStore.getPost(db, r.post_id)).thread_id, thread.id);// A reply to that Discord-origin post (mapped 'in') lands in the same thread too.-- const inId = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(r.post_id).external_message_id;-- const r2 = inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, message_reference: { message_id: inId }, content: 'reply to a reply', author: { id: LEE, username: 'lee' } }));-- assert.strictEqual(forumStore.getPost(db, r2.post_id).relay_author, 'lee');++ const inId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(r.post_id)).external_message_id;++ const r2 = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, message_reference: { message_id: inId }, content: 'reply to a reply', author: { id: LEE, username: 'lee' } }));++ assert.strictEqual((await forumStore.getPost(db, r2.post_id)).relay_author, 'lee');});await check('everything else is ignored: webhooks (the relay\'s own), bots, the bot itself, system messages, non-replies, other channels, inbound off', async () => {-- const n = postsOf(thread.id).length;++ const n = (await postsOf(thread.id)).length;const cases = [[msg({ webhook_id: '1' }), /webhook message/],[msg({ author: { id: LEE, username: 'lee', bot: true } }), /bot or system/],@@@ -203,33 +204,33 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==[msg({ type: 0, content: '', message_reference: { type: 1, message_id: threadMsg, channel_id: CHANNEL } }), /not a reply to a relayed message/], // a forward];for (const [m, re] of cases) {-- const r = inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });++ const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });assert.strictEqual(r.status, 'ignored', JSON.stringify(m));assert.match(r.reason, re);}-- relay.updateMapping(1, { inbound: false });-- assert.match(inbound.handle('MESSAGE_CREATE', msg()).reason, /inbound is off/);-- relay.updateMapping(1, { inbound: true, discord_channel_id: '7000000000000000002' });-- assert.match(inbound.handle('MESSAGE_CREATE', msg()).reason, /not a mapped channel/);-- relay.updateMapping(1, { discord_channel_id: CHANNEL });-- assert.strictEqual(postsOf(thread.id).length, n);-- assert.strictEqual(failures().length, 0, 'ignoring is not a failure');++ await relay.updateMapping(1, { inbound: false });++ assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /inbound is off/);++ await relay.updateMapping(1, { inbound: true, discord_channel_id: '7000000000000000002' });++ assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /not a mapped channel/);++ await relay.updateMapping(1, { discord_channel_id: CHANNEL });++ assert.strictEqual((await postsOf(thread.id)).length, n);++ assert.strictEqual((await failures()).length, 0, 'ignoring is not a failure');});await check('limits: a long message is cut, a fourth message a minute is refused, an empty one explains the intent; attachments are noted', async () => {-- const long = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000020', username: 'long' }, content: 'x'.repeat(500) }));-- const body = forumStore.getPost(db, long.post_id).body_markdown;++ const long = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000020', username: 'long' }, content: 'x'.repeat(500) }));++ const body = (await forumStore.getPost(db, long.post_id)).body_markdown;assert.strictEqual(body.length, 200);assert.ok(body.endsWith('…'));const talker = { id: '4000000000000000021', username: 'talker' };-- for (let i = 0; i < 3; i++) assert.strictEqual(inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: `m${i}` })).status, 'applied');-- const r = inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: 'one too many' }));++ for (let i = 0; i < 3; i++) assert.strictEqual((await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: `m${i}` }))).status, 'applied');++ const r = await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: 'one too many' }));assert.deepStrictEqual([r.status, r.error], ['failed', 'rate limited']);-- const empty = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000022', username: 'quiet' }, content: '' }));++ const empty = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000022', username: 'quiet' }, content: '' }));assert.match(empty.error, /MESSAGE CONTENT intent/);-- const pic = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000023', username: 'pics' }, content: '', attachments: [{ id: '1' }, { id: '2' }] }));-- assert.strictEqual(forumStore.getPost(db, pic.post_id).body_markdown, '_(2 attachments on Discord)_');-- const f = failures();++ const pic = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000023', username: 'pics' }, content: '', attachments: [{ id: '1' }, { id: '2' }] }));++ assert.strictEqual((await forumStore.getPost(db, pic.post_id)).body_markdown, '_(2 attachments on Discord)_');++ const f = await failures();assert.deepStrictEqual(f.map((x) => x.error.slice(0, 12)), ['rate limited', 'the message ']);assert.deepStrictEqual([f[0].event, f[0].external_author_id, f[0].mapping_id], ['MESSAGE_CREATE', talker.id, 1]);assert.ok(!JSON.stringify(f).includes('one too many'), 'failures never keep the text');@@@ -239,50 +240,50 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==await check('closed threads refuse replies from Discord and say why: locked, members-only, deleted', async () => {const t = (await forum.createThread(samV, 'general', { title: 'Soon closed', body: 'x' })).thread;await settle();-- const anchor = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(t.id).external_message_id;++ const anchor = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(t.id)).external_message_id;const to = (extra = {}) => msg({ author: { id: '4000000000000000030', username: 'late' }, message_reference: { message_id: anchor }, ...extra });-- forumStore.setThreadFlags(db, t.id, { locked: true });-- assert.strictEqual(inbound.handle('MESSAGE_CREATE', to()).error, 'the thread is locked');-- forumStore.setThreadFlags(db, t.id, { locked: false });-- forumStore.setThreadMembersOnly(db, t.id, sam);-- assert.strictEqual(inbound.handle('MESSAGE_CREATE', to()).error, 'the thread is not public');-- forumStore.setThreadMembersOnly(db, t.id, null);-- forumStore.softDeleteThread(db, t.id);-- assert.strictEqual(inbound.handle('MESSAGE_CREATE', to()).error, 'the thread was deleted');-- assert.strictEqual(postsOf(t.id).length, 1, 'only the opening post');++ await forumStore.setThreadFlags(db, t.id, { locked: true });++ assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is locked');++ await forumStore.setThreadFlags(db, t.id, { locked: false });++ await forumStore.setThreadMembersOnly(db, t.id, sam);++ assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is not public');++ await forumStore.setThreadMembersOnly(db, t.id, null);++ await forumStore.softDeleteThread(db, t.id);++ assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread was deleted');++ assert.strictEqual((await postsOf(t.id)).length, 1, 'only the opening post');});await check('edits and deletes on Discord follow through the map; a delete of the relay\'s own message leaves the thread', async () => {const m = msg({ author: { id: '4000000000000000040', username: 'editor' }, content: 'first words' });-- const { post_id: pid } = inbound.handle('MESSAGE_CREATE', m);-- const r = inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, content: 'second words <@4000000000000000003>', mentions: [{ id: LEE, username: 'lee' }], edited_timestamp: new Date().toISOString() });++ const { post_id: pid } = await inbound.handle('MESSAGE_CREATE', m);++ const r = await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, content: 'second words <@4000000000000000003>', mentions: [{ id: LEE, username: 'lee' }], edited_timestamp: new Date().toISOString() });assert.strictEqual(r.status, 'applied');-- const p = forumStore.getPost(db, pid);++ const p = await forumStore.getPost(db, pid);assert.deepStrictEqual([p.body_markdown, p.revision], ['second words @lee', 2]);-- assert.deepStrictEqual(forumStore.listPostVersions(db, pid).map((v) => v.edited_by), [null, 'discord']);-- assert.match(inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, embeds: [] }).reason, /no text change/);-- assert.match(inbound.handle('MESSAGE_UPDATE', { id: threadMsg, channel_id: CHANNEL, content: 'edited on Discord?' }).reason, /relay's own/);-- const other = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000041', username: 'bulk' }, content: 'bulk me' }));-- const otherId = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(other.post_id).external_message_id;-- assert.strictEqual(inbound.handle('MESSAGE_DELETE', { id: m.id, channel_id: CHANNEL }).status, 'applied');-- assert.ok(forumStore.getPost(db, pid).deleted_at, 'the post is deleted');-- const bulk = inbound.handle('MESSAGE_DELETE_BULK', { ids: [otherId, '1234567890123456789'], channel_id: CHANNEL });++ assert.deepStrictEqual((await forumStore.listPostVersions(db, pid)).map((v) => v.edited_by), [null, 'discord']);++ assert.match((await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, embeds: [] })).reason, /no text change/);++ assert.match((await inbound.handle('MESSAGE_UPDATE', { id: threadMsg, channel_id: CHANNEL, content: 'edited on Discord?' })).reason, /relay's own/);++ const other = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000041', username: 'bulk' }, content: 'bulk me' }));++ const otherId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(other.post_id)).external_message_id;++ assert.strictEqual((await inbound.handle('MESSAGE_DELETE', { id: m.id, channel_id: CHANNEL })).status, 'applied');++ assert.ok((await forumStore.getPost(db, pid)).deleted_at, 'the post is deleted');++ const bulk = await inbound.handle('MESSAGE_DELETE_BULK', { ids: [otherId, '1234567890123456789'], channel_id: CHANNEL });assert.deepStrictEqual(bulk.map((x) => x.status), ['applied', 'ignored']);-- assert.ok(forumStore.getPost(db, other.post_id).deleted_at);++ assert.ok((await forumStore.getPost(db, other.post_id)).deleted_at);// A moderator on Discord removed the relay's announcement: the map knows, the thread stays, no edit goes there again.-- assert.match(inbound.handle('MESSAGE_DELETE', { id: threadMsg, channel_id: CHANNEL }).note, /thread stays/);-- assert.ok(forumStore.getThread(db, thread.id), 'the thread is still here');-- assert.ok(db.prepare('SELECT external_deleted_at FROM relay_message_map WHERE external_message_id = ?').get(threadMsg).external_deleted_at);-- assert.strictEqual(relay.enqueueEdit(forumStore.getPost(db, db.prepare('SELECT id FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id).id)), 0);++ assert.match((await inbound.handle('MESSAGE_DELETE', { id: threadMsg, channel_id: CHANNEL })).note, /thread stays/);++ assert.ok(await forumStore.getThread(db, thread.id), 'the thread is still here');++ assert.ok((await db.prepare('SELECT external_deleted_at FROM relay_message_map WHERE external_message_id = ?').get(threadMsg)).external_deleted_at);++ assert.strictEqual(await relay.enqueueEdit(await forumStore.getPost(db, (await db.prepare('SELECT id FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id)).id)), 0);});await check('loop prevention: nothing from Discord is ever relayed back out', async () => {await settle();const before = hook.hits.length;-- const fromDiscord = postsOf(thread.id).filter((p) => p.origin === 'discord');++ const fromDiscord = (await postsOf(thread.id)).filter((p) => p.origin === 'discord');assert.ok(fromDiscord.length >= 5);-- for (const p of fromDiscord) assert.strictEqual(relay.enqueuePost(p, forumStore.getThread(db, thread.id), general), 0);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM relay_deliveries WHERE post_id IN (SELECT id FROM posts WHERE origin = \'discord\')').get().n, 0);++ for (const p of fromDiscord) assert.strictEqual(await relay.enqueuePost(p, await forumStore.getThread(db, thread.id), general), 0);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM relay_deliveries WHERE post_id IN (SELECT id FROM posts WHERE origin = \'discord\')').get()).n, 0);await settle();assert.strictEqual(hook.hits.length, before, 'no webhook call for anything that came from Discord');});@@@ -303,19 +304,20 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });-- await waitFor(() => t.app.locals.relay.status().inbound.state === 'ready', 'the app\'s gateway READY');++ await waitFor(async () => (await t.app.locals.relay.status()).inbound.state === 'ready', 'the app\'s gateway READY');assert.strictEqual(sent(gw, 2)[0].d.token, 'app-bot-token-not-real');const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true } });assert.strictEqual(m.json().mapping.inbound, true);const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Ask Discord', body: 'what do you think?' } });-- await waitFor(() => t.db.prepare("SELECT 1 FROM relay_message_map WHERE local_type = 'thread'").get(), 'the thread on Discord');-- const anchor = t.db.prepare("SELECT external_message_id, external_channel_id FROM relay_message_map WHERE local_type = 'thread'").get();++ await waitFor(async () => await t.db.prepare("SELECT 1 FROM relay_message_map WHERE local_type = 'thread'").get(), 'the thread on Discord');++ const anchor = await t.db.prepare("SELECT external_message_id, external_channel_id FROM relay_message_map WHERE local_type = 'thread'").get();gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });const locked = nextId();++ await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');const page = await call(`/s/general/t/${th.json().thread.slug}`);assert.strictEqual(page.status, 200);assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');-- t.db.prepare('UPDATE threads SET locked = 1').run();++ await t.db.prepare('UPDATE threads SET locked = 1').run();gw.dispatch('MESSAGE_CREATE', { id: locked, channel_id: anchor.external_channel_id, type: 19, content: 'too late', author: { id: KIM, username: 'kim' }, message_reference: { message_id: anchor.external_message_id } });assert.strictEqual((await call('/api/v1/relay/inbound', { cookie: samJwt })).status, 403);const inb = await call('/api/v1/relay/inbound', { cookie: adminJwt });@@@ -18,7 +18,7 @@ const path = require('path');const { ids } = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');const { startWebhooks } = require('./helpers/fake-discord');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const forumStore = require('../server/forum/store');const { createDiscordRelay } = require('../server/relay/discord');const { createForumService } = require('../server/forum/service');@@@ -26,39 +26,39 @@ const { sqlTime } = require('../server/http/v1');(async () => {const hook = await startWebhooks();-- const db = openDb(':memory:');-- const general = forumStore.getSpace(db, 'general');-- const feedback = forumStore.getSpace(db, 'feedback');++ const db = await testDb();++ const general = await forumStore.getSpace(db, 'general');++ const feedback = await forumStore.getSpace(db, 'feedback');const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general`, DISCORD_WEBHOOK_FEEDBACK: `${hook.url}/api/webhooks/2/feedback` };let clock = Date.parse('2026-09-22T12:00:00Z');const now = () => clock;const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env, enabled: true, baseMs: 60_000, maxAttempts: 3, now });const alex = ids.newId('user');-- require('../server/pastes/store').upsertProjection(db, { subject_id: alex, username: 'alex', display_name: 'Alex @everyone' });-- relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });-- relay.addMapping({ space_id: feedback.id, webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' });-- const newThread = (space, title, extra = {}) => forumStore.createThread(db, { space_id: space.id, title, author_subject: alex, body_markdown: '**Hello** there, [link](https://x.y)', ...extra }).thread;-- const deliveries = () => db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();-- const mapOf = (type, id) => db.prepare('SELECT * FROM relay_message_map WHERE local_type = ? AND local_id = ? ORDER BY id').all(type, id);++ await require('../server/pastes/store').upsertProjection(db, { subject_id: alex, username: 'alex', display_name: 'Alex @everyone' });++ await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });++ await relay.addMapping({ space_id: feedback.id, webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' });++ const newThread = async (space, title, extra = {}) => (await forumStore.createThread(db, { space_id: space.id, title, author_subject: alex, body_markdown: '**Hello** there, [link](https://x.y)', ...extra })).thread;++ const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();++ const mapOf = async (type, id) => await db.prepare('SELECT * FROM relay_message_map WHERE local_type = ? AND local_id = ? ORDER BY id').all(type, id);const settle = () => new Promise((r) => setImmediate(r));const EMPTY = { delivered: 0, retry: 0, failed: 0, skipped: 0 };await check('off by default: nothing is queued or sent', async () => {const off = createDiscordRelay({ db, env, now });assert.strictEqual(off.enabled, false);-- const th = newThread(general, 'Quiet thread');-- assert.strictEqual(off.enqueueThread(th, general), 0);-- assert.strictEqual(off.enqueuePost(forumStore.addPost(db, { thread_id: th.id, author_subject: alex, body_markdown: 'shh' }), th, general), 0);++ const th = await newThread(general, 'Quiet thread');++ assert.strictEqual(await off.enqueueThread(th, general), 0);++ assert.strictEqual(await off.enqueuePost(await forumStore.addPost(db, { thread_id: th.id, author_subject: alex, body_markdown: 'shh' }), th, general), 0);assert.deepStrictEqual(await off.drain(), EMPTY);-- assert.strictEqual(deliveries().length, 0);-- const st = off.status();++ assert.strictEqual((await deliveries()).length, 0);++ const st = await off.status();assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled], [false, false, false]);});await check('a new thread is posted once with ?wait=true, attribution, a link and no mentions; its message id is mapped', async () => {-- const th = newThread(general, 'Relay me <please>');-- assert.strictEqual(relay.enqueueThread(th, general), 1);-- assert.strictEqual(relay.enqueueThread(th, general), 0, 'dedupe key per (thread, mapping)');++ const th = await newThread(general, 'Relay me <please>');++ assert.strictEqual(await relay.enqueueThread(th, general), 1);++ assert.strictEqual(await relay.enqueueThread(th, general), 0, 'dedupe key per (thread, mapping)');await settle();await relay.drain();assert.strictEqual(hook.hits.length, 1);@@@ -71,40 +71,40 @@ const { sqlTime } = require('../server/http/v1');assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/s/general/t/relay-me-please');assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · s/general');-- const d = deliveries().find((x) => x.thread_id === th.id);++ const d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);-- const [row] = mapOf('thread', th.id);++ const [row] = await mapOf('thread', th.id);assert.deepStrictEqual([row.platform, row.direction, row.mapping_id, row.thread_id, row.external_channel_id, row.external_thread_id, row.external_webhook_id],['discord', 'out', 1, th.id, '2000000000000000001', null, '1']);assert.ok(hook.messages.has(row.external_message_id));-- assert.strictEqual(db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 1').get().discord_channel_id, '2000000000000000001', 'the mapping learned its channel');++ assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 1').get()).discord_channel_id, '2000000000000000001', 'the mapping learned its channel');await relay.drain();assert.strictEqual(hook.hits.length, 1, 'a delivered thread is never sent again');// Even a create queued again under another key is not sent twice: the map already has it.-- db.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, 'replay-of-the-same', ?)").run(th.id, sqlTime(clock));++ await db.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, 'replay-of-the-same', ?)").run(th.id, sqlTime(clock));await relay.drain();assert.strictEqual(hook.hits.length, 1);-- assert.deepStrictEqual([deliveries().pop().status, deliveries().pop().last_error], ['delivered', 'already on Discord']);++ assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['delivered', 'already on Discord']);});await check('the external message map is unique both ways', async () => {-- const th = newThread(general, 'Unique map');-- const put = (mapping, type, local, external) => db.prepare(`INSERT INTO relay_message_map (mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)++ const th = await newThread(general, 'Unique map');++ const put = async (mapping, type, local, external) => await db.prepare(`INSERT INTO relay_message_map (mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)VALUES (?, 'out', ?, ?, ?, '2000000000000000001', ?)`).run(mapping, type, local, th.id, external);-- put(1, 'thread', th.id, '1900000000000000001');-- assert.throws(() => put(2, 'post', 999, '1900000000000000001'), /UNIQUE/, 'one Discord message is one local object');-- assert.throws(() => put(1, 'thread', th.id, '1900000000000000002'), /UNIQUE/, 'one local object is one message per mapping');-- put(2, 'thread', th.id, '1900000000000000003'); // the same thread through another mapping is fine-- db.prepare('DELETE FROM relay_message_map WHERE thread_id = ?').run(th.id);++ await put(1, 'thread', th.id, '1900000000000000001');++ await assert.rejects(async () => await put(2, 'post', 999, '1900000000000000001'), /duplicate key/, 'one Discord message is one local object');++ await assert.rejects(async () => await put(1, 'thread', th.id, '1900000000000000002'), /duplicate key/, 'one local object is one message per mapping');++ await put(2, 'thread', th.id, '1900000000000000003'); // the same thread through another mapping is fine++ await db.prepare('DELETE FROM relay_message_map WHERE thread_id = ?').run(th.id);});await check('retries with exponential backoff on 5xx, then delivers', async () => {hook.hits.length = 0;hook.plan.push({ status: 502, body: { message: 'bad gateway' } });-- const th = newThread(feedback, 'Flaky Discord');-- relay.enqueueThread(th, feedback);++ const th = await newThread(feedback, 'Flaky Discord');++ await relay.enqueueThread(th, feedback);await settle(); await relay.drain();-- let d = deliveries().find((x) => x.thread_id === th.id);++ let d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 1, 502]);assert.match(d.last_error, /Discord answered 502: bad gateway/);assert.strictEqual(d.next_attempt_at, '2026-09-22 12:01:00', 'first retry after baseMs');@@@ -113,114 +113,114 @@ const { sqlTime } = require('../server/http/v1');clock += 60_000;hook.plan.push({ status: 500 });await relay.drain();-- d = deliveries().find((x) => x.thread_id === th.id);++ d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.attempts, d.next_attempt_at], ['pending', 2, '2026-09-22 12:03:00'], 'second retry after 2 × baseMs');clock += 120_000;await relay.drain();-- d = deliveries().find((x) => x.thread_id === th.id);++ d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.attempts, d.last_error], ['delivered', 3, null]);assert.strictEqual(hook.hits.length, 3);});await check('429 honours retry_after and holds that webhook for the pass; used-up attempts end as failed; other 4xx fail at once', async () => {-- const th = newThread(general, 'Rate limited');-- const th2 = newThread(general, 'Behind it');++ const th = await newThread(general, 'Rate limited');++ const th2 = await newThread(general, 'Behind it');hook.plan.push({ status: 429, body: { message: 'You are being rate limited.', retry_after: 300 } });-- relay.enqueueThread(th, general);-- relay.enqueueThread(th2, general);++ await relay.enqueueThread(th, general);++ await relay.enqueueThread(th2, general);const before = hook.hits.length;await settle(); await relay.drain();assert.strictEqual(hook.hits.length, before + 1, 'the next delivery to the same webhook waits for the next pass');-- let d = deliveries().find((x) => x.thread_id === th.id);++ let d = (await deliveries()).find((x) => x.thread_id === th.id);assert.strictEqual(d.status, 'pending');assert.strictEqual(Date.parse(`${d.next_attempt_at.replace(' ', 'T')}Z`) - clock, 300_000);await relay.drain();-- assert.strictEqual(deliveries().find((x) => x.thread_id === th2.id).status, 'delivered');++ assert.strictEqual((await deliveries()).find((x) => x.thread_id === th2.id).status, 'delivered');clock += 300_000;hook.plan.push({ status: 503 }, { status: 503 });await relay.drain();clock += 10 * 60_000;await relay.drain();-- d = deliveries().find((x) => x.thread_id === th.id);++ d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['failed', 3, 503]);-- const gone = newThread(general, 'Webhook deleted');++ const gone = await newThread(general, 'Webhook deleted');hook.plan.push({ status: 404, body: { message: 'Unknown Webhook', code: 10015 } });-- relay.enqueueThread(gone, general);++ await relay.enqueueThread(gone, general);await settle(); await relay.drain();-- d = deliveries().find((x) => x.thread_id === gone.id);++ d = (await deliveries()).find((x) => x.thread_id === gone.id);assert.deepStrictEqual([d.status, d.attempts], ['failed', 1]);assert.match(d.last_error, /404: Unknown Webhook/);});await check('a missing webhook variable and network errors are retried, then it delivers; the URL never enters the DB', async () => {-- const showcase = forumStore.getSpace(db, 'showcase');-- relay.addMapping({ space_id: showcase.id, webhook_url_ref: 'DISCORD_WEBHOOK_SHOWCASE' });-- const th = newThread(showcase, 'Env later');-- relay.enqueueThread(th, showcase);++ const showcase = await forumStore.getSpace(db, 'showcase');++ await relay.addMapping({ space_id: showcase.id, webhook_url_ref: 'DISCORD_WEBHOOK_SHOWCASE' });++ const th = await newThread(showcase, 'Env later');++ await relay.enqueueThread(th, showcase);await settle(); await relay.drain();-- let d = deliveries().find((x) => x.thread_id === th.id);++ let d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.last_error], ['pending', 'webhook URL variable DISCORD_WEBHOOK_SHOWCASE is not set']);env.DISCORD_WEBHOOK_SHOWCASE = 'http://127.0.0.1:1/api/webhooks/refused';clock += 60_000;await relay.drain();-- d = deliveries().find((x) => x.thread_id === th.id);++ d = (await deliveries()).find((x) => x.thread_id === th.id);assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 2, null], 'a network error is retried');assert.ok(d.last_error);env.DISCORD_WEBHOOK_SHOWCASE = `${hook.url}/api/webhooks/3/showcase`;clock += 120_000;await relay.drain();-- d = deliveries().find((x) => x.thread_id === th.id);++ d = (await deliveries()).find((x) => x.thread_id === th.id);assert.strictEqual(d.status, 'delivered');-- const everything = JSON.stringify(db.prepare('SELECT * FROM relay_mappings').all()) + JSON.stringify(deliveries()) + JSON.stringify(db.prepare('SELECT * FROM relay_message_map').all());++ const everything = JSON.stringify(await db.prepare('SELECT * FROM relay_mappings').all()) + JSON.stringify(await deliveries()) + JSON.stringify(await db.prepare('SELECT * FROM relay_message_map').all());assert.ok(!everything.includes('/api/webhooks/'), 'no webhook URL stored anywhere');});await check('loop prevention: Discord-origin threads and posts are never relayed; members/staff spaces never leave the site', async () => {const before = hook.hits.length;-- const fromDiscord = newThread(general, 'Came from Discord', { origin: 'discord' });-- assert.strictEqual(relay.enqueueThread(fromDiscord, general), 0);++ const fromDiscord = await newThread(general, 'Came from Discord', { origin: 'discord' });++ assert.strictEqual(await relay.enqueueThread(fromDiscord, general), 0);// Even a delivery row that got queued somehow is refused at send time.-- db.prepare('INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, ?, ?)').run(fromDiscord.id, `thread:${fromDiscord.id}:mapping:1`, sqlTime(clock));++ await db.prepare('INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, ?, ?)').run(fromDiscord.id, `thread:${fromDiscord.id}:mapping:1`, sqlTime(clock));await relay.drain();-- const d = deliveries().find((x) => x.thread_id === fromDiscord.id);++ const d = (await deliveries()).find((x) => x.thread_id === fromDiscord.id);assert.deepStrictEqual([d.status, d.last_error], ['failed', 'loop prevention: thread came from Discord']);// A reply that came from Discord, in a thread that is on Discord, stays here too.-- const th = newThread(general, 'Mixed replies');-- relay.enqueueThread(th, general);++ const th = await newThread(general, 'Mixed replies');++ await relay.enqueueThread(th, general);await settle(); await relay.drain();-- const inbound = forumStore.addPost(db, { thread_id: th.id, origin: 'discord', body_markdown: 'from discord', relay_author: 'Kim' });-- assert.strictEqual(relay.enqueuePost(inbound, th, general), 0);-- db.prepare('INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, 1, ?, ?)').run(th.id, inbound.id, `post:${inbound.id}:mapping:1`, sqlTime(clock));++ const inbound = await forumStore.addPost(db, { thread_id: th.id, origin: 'discord', body_markdown: 'from discord', relay_author: 'Kim' });++ assert.strictEqual(await relay.enqueuePost(inbound, th, general), 0);++ await db.prepare('INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, 1, ?, ?)').run(th.id, inbound.id, `post:${inbound.id}:mapping:1`, sqlTime(clock));await relay.drain();-- assert.deepStrictEqual([deliveries().pop().status, deliveries().pop().last_error], ['failed', 'loop prevention: post came from Discord']);-- db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();-- const ins = forumStore.getSpace(db, 'insiders');-- relay.addMapping({ space_id: ins.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });-- assert.strictEqual(relay.enqueueThread(newThread(ins, 'Members only'), ins), 0);++ assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['failed', 'loop prevention: post came from Discord']);++ await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();++ const ins = await forumStore.getSpace(db, 'insiders');++ await relay.addMapping({ space_id: ins.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });++ assert.strictEqual(await relay.enqueueThread(await newThread(ins, 'Members only'), ins), 0);assert.strictEqual(hook.hits.length, before + 1, 'only the public thread went out');});await check('disabled mappings queue nothing; retry() puts a failed delivery back with a fresh budget; drop() keeps it as dropped', async () => {-- relay.setMappingEnabled(1, false);-- assert.strictEqual(relay.enqueueThread(newThread(general, 'Mapping off'), general), 0);-- relay.setMappingEnabled(1, true);-- const failed = deliveries().find((x) => x.status === 'failed' && x.last_status === 404);-- assert.strictEqual(relay.retry(failed.id), 1);++ await relay.setMappingEnabled(1, false);++ assert.strictEqual(await relay.enqueueThread(await newThread(general, 'Mapping off'), general), 0);++ await relay.setMappingEnabled(1, true);++ const failed = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 404);++ assert.strictEqual(await relay.retry(failed.id), 1);await settle(); await relay.drain();-- assert.strictEqual(deliveries().find((x) => x.id === failed.id).status, 'delivered');-- assert.strictEqual(relay.retry(failed.id), 0, 'delivered ones stay delivered');-- const dead = deliveries().find((x) => x.status === 'failed' && x.last_status === 503);-- assert.strictEqual(relay.drop(dead.id), 1);-- assert.strictEqual(deliveries().find((x) => x.id === dead.id).status, 'dropped');-- assert.strictEqual(relay.drop(dead.id), 0);-- assert.strictEqual(relay.status().deliveries.dropped, 1);++ assert.strictEqual((await deliveries()).find((x) => x.id === failed.id).status, 'delivered');++ assert.strictEqual(await relay.retry(failed.id), 0, 'delivered ones stay delivered');++ const dead = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 503);++ assert.strictEqual(await relay.drop(dead.id), 1);++ assert.strictEqual((await deliveries()).find((x) => x.id === dead.id).status, 'dropped');++ assert.strictEqual(await relay.drop(dead.id), 0);++ assert.strictEqual((await relay.status()).deliveries.dropped, 1);});// ── replies, edits and deletes through the forum service ─────────────────────────const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });const sam = ids.newId('user');-- require('../server/pastes/store').upsertProjection(db, { subject_id: sam, username: 'sam', display_name: 'Sam' });++ await require('../server/pastes/store').upsertProjection(db, { subject_id: sam, username: 'sam', display_name: 'Sam' });const samV = { kind: 'user', subject: sam, staff: false };const alexV = { kind: 'user', subject: alex, staff: false };const modV = { kind: 'user', subject: ids.newId('user'), staff: true };@@@ -237,7 +237,7 @@ const { sqlTime } = require('../server/http/v1');assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.community\/s\/general\/t\/replies-go-too#post-\d+>$/);assert.strictEqual(r.body.embeds[0].description, 'First reply');assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });-- const [row] = mapOf('post', post.id);++ const [row] = await mapOf('post', post.id);assert.deepStrictEqual([row.direction, row.thread_id], ['out', thread.id]);// The thread's own message failing for now: the reply waits, without using an attempt.hook.hits.length = 0;@@@ -245,26 +245,26 @@ const { sqlTime } = require('../server/http/v1');const t2 = await forum.createThread(samV, 'general', { title: 'Thread first', body: 'x' });const p2 = (await forum.reply(alexV, 'general', t2.thread.slug, { body: 'waits' })).post;await drainAll();-- const reply = deliveries().find((x) => x.post_id === p2.id);++ const reply = (await deliveries()).find((x) => x.post_id === p2.id);assert.deepStrictEqual([reply.status, reply.attempts, reply.last_error], ['pending', 0, "waiting for the thread's own message"]);assert.strictEqual(hook.hits.length, 1, 'only the thread was tried');clock += 60_000;await drainAll();assert.deepStrictEqual(hook.hits.map((h) => h.body.content.startsWith('New thread') ? 'thread' : 'reply'), ['thread', 'thread', 'reply'], 'the thread, then its reply');-- assert.strictEqual(deliveries().find((x) => x.post_id === p2.id).status, 'delivered');++ assert.strictEqual((await deliveries()).find((x) => x.post_id === p2.id).status, 'delivered');// A thread that was never relayed (made before the mapping): its replies are not either.-- const old = newThread(general, 'Before the mapping');-- assert.strictEqual(relay.enqueuePost(forumStore.addPost(db, { thread_id: old.id, author_subject: sam, body_markdown: 'late' }), old, general), 0);++ const old = await newThread(general, 'Before the mapping');++ assert.strictEqual(await relay.enqueuePost(await forumStore.addPost(db, { thread_id: old.id, author_subject: sam, body_markdown: 'late' }), old, general), 0);});await check('a mapping that names a Discord thread posts into it (thread_id) and edits and deletes there', async () => {hook.hits.length = 0;-- relay.updateMapping(2, { discord_thread_id: '3000000000000000002' });++ await relay.updateMapping(2, { discord_thread_id: '3000000000000000002' });const { thread } = await forum.createThread(samV, 'feedback', { title: 'Into a Discord thread', body: 'hi' });const { post } = await forum.reply(samV, 'feedback', thread.slug, { body: 'reply in thread' });await drainAll();assert.deepStrictEqual(hook.hits.map((h) => h.path), ['/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002', '/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002'], JSON.stringify(hook.hits.map((h) => h.path)));-- const [row] = mapOf('post', post.id);++ const [row] = await mapOf('post', post.id);assert.deepStrictEqual([row.external_channel_id, row.external_thread_id], ['3000000000000000002', '3000000000000000002']);await forum.editPost(samV, post.id, { body: 'reply in thread, edited' });await drainAll();@@@ -274,8 +274,8 @@ const { sqlTime } = require('../server/http/v1');`PATCH /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,`DELETE /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,]);-- assert.strictEqual(db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 2').get().discord_channel_id, '2000000000000000002', 'the channel learned before stays');-- relay.updateMapping(2, { discord_thread_id: null });++ assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 2').get()).discord_channel_id, '2000000000000000002', 'the channel learned before stays');++ await relay.updateMapping(2, { discord_thread_id: null });});await check('edits follow through the map: a reply\'s message and, for the opening post, the thread\'s message are PATCHed', async () => {@@@ -283,8 +283,8 @@ const { sqlTime } = require('../server/http/v1');const { thread, post: opening } = await forum.createThread(samV, 'general', { title: 'Edit me', body: 'Version one' });const { post } = await forum.reply(samV, 'general', thread.slug, { body: 'Reply one' });await drainAll();-- const threadMsg = mapOf('thread', thread.id)[0].external_message_id;-- const postMsg = mapOf('post', post.id)[0].external_message_id;++ const threadMsg = (await mapOf('thread', thread.id))[0].external_message_id;++ const postMsg = (await mapOf('post', post.id))[0].external_message_id;await forum.editPost(samV, post.id, { body: 'Reply **two**' });await forum.editPost(samV, opening.id, { body: 'Version two' });await forum.editPost(samV, opening.id, { body: 'Version two' }); // no change: no revision, nothing queued@@@ -295,18 +295,18 @@ const { sqlTime } = require('../server/http/v1');assert.strictEqual(edits[1].body.embeds[0].description, 'Version two');assert.deepStrictEqual(edits[1].body.allowed_mentions, { parse: [] });assert.strictEqual(hook.messages.get(threadMsg).body.embeds[0].description, 'Version two');-- const keys = deliveries().filter((d) => d.action === 'edit').map((d) => d.dedupe_key);++ const keys = (await deliveries()).filter((d) => d.action === 'edit').map((d) => d.dedupe_key);assert.ok(keys.includes(`edit:post:${post.id}:mapping:1:r2`) && keys.includes(`edit:thread:${thread.id}:mapping:1:r2`), keys.join());// Someone deleted the reply's message on Discord: the next edit finds nothing and says so.hook.messages.get(postMsg).deleted = true;await forum.editPost(samV, post.id, { body: 'Reply three' });await drainAll();-- const last = deliveries().pop();++ const last = (await deliveries()).pop();assert.deepStrictEqual([last.action, last.status, last.last_error], ['edit', 'skipped', 'the message is gone on Discord']);-- assert.ok(mapOf('post', post.id)[0].external_deleted_at, 'the map knows it is gone');-- const queued = deliveries().length;++ assert.ok((await mapOf('post', post.id))[0].external_deleted_at, 'the map knows it is gone');++ const queued = (await deliveries()).length;await forum.editPost(samV, post.id, { body: 'Reply four' });-- assert.strictEqual(deliveries().length, queued, 'nothing more is queued for a message that is gone');++ assert.strictEqual((await deliveries()).length, queued, 'nothing more is queued for a message that is gone');// An edit right before a delete is skipped: only the delete goes out.const { post: p2 } = await forum.reply(samV, 'general', thread.slug, { body: 'short-lived' });await drainAll();@@@ -315,7 +315,7 @@ const { sqlTime } = require('../server/http/v1');await forum.deletePost(samV, p2.id);await drainAll();assert.deepStrictEqual(hook.hits.map((h) => h.method), ['DELETE']);-- assert.strictEqual(deliveries().find((d) => d.post_id === p2.id && d.action === 'edit').status, 'skipped');++ assert.strictEqual((await deliveries()).find((d) => d.post_id === p2.id && d.action === 'edit').status, 'skipped');});await check('deletes follow through the map: a reply, and a thread with every reply of it; what was not sent yet is skipped', async () => {@@@ -324,7 +324,7 @@ const { sqlTime } = require('../server/http/v1');const a = (await forum.reply(samV, 'general', thread.slug, { body: 'a' })).post;const b = (await forum.reply(alexV, 'general', thread.slug, { body: 'b' })).post;await drainAll();-- const msgs = { thread: mapOf('thread', thread.id)[0].external_message_id, a: mapOf('post', a.id)[0].external_message_id, b: mapOf('post', b.id)[0].external_message_id };++ const msgs = { thread: (await mapOf('thread', thread.id))[0].external_message_id, a: (await mapOf('post', a.id))[0].external_message_id, b: (await mapOf('post', b.id))[0].external_message_id };await forum.deletePost(modV, a.id); // moderated awayawait drainAll();assert.deepStrictEqual(hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname), [`/api/webhooks/1/general/messages/${msgs.a}`]);@@@ -334,15 +334,15 @@ const { sqlTime } = require('../server/http/v1');const deleted = hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname).sort();assert.deepStrictEqual(deleted, [msgs.thread, msgs.a, msgs.b].map((m) => `/api/webhooks/1/general/messages/${m}`).sort());assert.ok(!hook.hits.some((h) => h.method === 'POST' && /c, never sent/.test(JSON.stringify(h.body))), 'the reply queued before the delete never went out');-- assert.deepStrictEqual([deliveries().find((d) => d.post_id === c.id).status], ['skipped']);-- assert.ok(mapOf('thread', thread.id).every((r) => r.external_deleted_at));++ assert.deepStrictEqual([(await deliveries()).find((d) => d.post_id === c.id).status], ['skipped']);++ assert.ok((await mapOf('thread', thread.id)).every((r) => r.external_deleted_at));// A delete whose message is already gone on Discord counts as done.const t3 = (await forum.createThread(samV, 'general', { title: 'Gone already', body: 'x' })).thread;await drainAll();-- hook.messages.get(mapOf('thread', t3.id)[0].external_message_id).deleted = true;++ hook.messages.get((await mapOf('thread', t3.id))[0].external_message_id).deleted = true;await forum.deleteThread(samV, 'general', t3.slug);await drainAll();-- const del = deliveries().pop();++ const del = (await deliveries()).pop();assert.deepStrictEqual([del.action, del.status, del.last_status, del.last_error], ['delete', 'delivered', 404, 'the message was already gone on Discord']);});@@@ -357,51 +357,20 @@ const { sqlTime } = require('../server/http/v1');await drainAll();assert.strictEqual(hook.hits.filter((h) => h.method === 'DELETE').length, 2, 'the thread\'s and the reply\'s messages were deleted');// Replies in the gated thread (the creator may still post) never go out.-- const gatedThread = db.prepare('SELECT * FROM threads WHERE id = ?').get(thread.id);-- const reply = forumStore.addPost(db, { thread_id: thread.id, author_subject: sam, body_markdown: 'members only' });-- assert.strictEqual(relay.enqueuePost(reply, gatedThread, general), 0);++ const gatedThread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(thread.id);++ const reply = await forumStore.addPost(db, { thread_id: thread.id, author_subject: sam, body_markdown: 'members only' });++ assert.strictEqual(await relay.enqueuePost(reply, gatedThread, general), 0);// A members-only thread from the start is never queued, nor is anything in a VIP space.-- const gated = newThread(general, 'VIP from the start', { members_only_owner: sam });-- assert.strictEqual(relay.enqueueThread(gated, general), 0);-- db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(sam, feedback.id);-- const vipSpace = forumStore.getSpace(db, 'feedback');-- assert.strictEqual(relay.enqueueThread(newThread(vipSpace, 'In a VIP space'), vipSpace), 0);-- db.prepare('UPDATE spaces SET members_only_owner = NULL WHERE id = ?').run(feedback.id);++ const gated = await newThread(general, 'VIP from the start', { members_only_owner: sam });++ assert.strictEqual(await relay.enqueueThread(gated, general), 0);++ await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(sam, feedback.id);++ const vipSpace = await forumStore.getSpace(db, 'feedback');++ assert.strictEqual(await relay.enqueueThread(await newThread(vipSpace, 'In a VIP space'), vipSpace), 0);++ await db.prepare('UPDATE spaces SET members_only_owner = NULL WHERE id = ?').run(feedback.id);await drainAll();assert.ok(!hook.hits.slice(posted).some((h) => h.method !== 'DELETE'), 'nothing but the two deletes');});-- await check('an older database: relay_deliveries is rebuilt once, rows kept, the new columns in place', async () => {-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-relay-migrate-'));-- const file = path.join(dir, 'community.db');-- const old = openDb(file);-- const th = forumStore.createThread(old, { space_id: 1, title: 'Old row', body_markdown: 'x' }).thread;-- old.exec(`DROP TABLE relay_deliveries;-- CREATE TABLE relay_deliveries (id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,-- mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE, dedupe_key TEXT UNIQUE NOT NULL,-- status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')), attempts INTEGER NOT NULL DEFAULT 0,-- next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_status INTEGER, last_error TEXT, delivered_at DATETIME,-- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (thread_id, mapping_id));-- CREATE INDEX idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);`);-- old.prepare("INSERT INTO relay_mappings (space_id, webhook_url_ref) VALUES (1, 'DISCORD_WEBHOOK_GENERAL')").run();-- old.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, status, attempts, last_status) VALUES (?, 1, ?, 'delivered', 1, 204)").run(th.id, `thread:${th.id}:mapping:1`);-- old.close();-- const reopened = openDb(file);-- const cols = reopened.prepare('PRAGMA table_info(relay_deliveries)').all().map((c) => c.name);-- for (const c of ['post_id', 'action', 'source', 'event_id']) assert.ok(cols.includes(c), c);-- const row = reopened.prepare('SELECT * FROM relay_deliveries').get();-- assert.deepStrictEqual([row.thread_id, row.dedupe_key, row.status, row.action, row.source, row.last_status], [th.id, `thread:${th.id}:mapping:1`, 'delivered', 'create', 'direct', 204]);-- reopened.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, status) VALUES (?, 1, 'x', 'dropped')").run(th.id);-- assert.ok(reopened.prepare("SELECT 1 FROM sqlite_master WHERE name = 'idx_relay_deliveries_due'").get());-- const mcols = reopened.prepare('PRAGMA table_info(relay_mappings)').all().map((c) => c.name);-- assert.ok(['discord_channel_id', 'discord_thread_id', 'inbound'].every((c) => mcols.includes(c)));-- reopened.close();-- const again = openDb(file); // idempotent-- assert.strictEqual(again.prepare('SELECT COUNT(*) AS n FROM relay_deliveries').get().n, 2);-- again.close();-- fs.rmSync(dir, { recursive: true, force: true });-- });--await check('app wiring: threads, replies, edits and deletes through the API are relayed; the admin endpoints are staff-only and URL-free', async () => {const t = await boot({authority: 'community',@@@ -418,8 +387,8 @@ const { sqlTime } = require('../server/http/v1');return t.get(p, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });};const r = t.app.locals.relay;-- assert.strictEqual(r.status().creates_from, 'forum', 'no EVENTS_URL: the forum queues creates');-- assert.deepStrictEqual([r.status().events_worker.enabled, r.status().inbound.enabled], [false, false]);++ assert.strictEqual((await r.status()).creates_from, 'forum', 'no EVENTS_URL: the forum queues creates');++ assert.deepStrictEqual([(await r.status()).events_worker.enabled, (await r.status()).inbound.enabled], [false, false]);assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: samJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 403);const bad = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'https://discord.com/api/webhooks/secret' } });assert.strictEqual(bad.status, 400);@@@ -497,18 +466,18 @@ const { sqlTime } = require('../server/http/v1');const svc = await map(ref, { token: net.signService({ cap: ['community.comment.moderate'] }) });assert.strictEqual(svc.status, 400, `${ref} as a service`);}-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get().n, 0);-- assert.throws(() => t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get()).n, 0);++ await assert.rejects(async () => await t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);// A mapping that predates the allow-list (straight into the table) is never sent to, and// does not reveal whether that variable is set.-- const gen = t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();-- t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(gen.id);++ const gen = await t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();++ await t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(gen.id);const listed = (await call('/api/v1/relay/mappings', { cookie: adminJwt })).json().mappings.find((x) => x.webhook_url_ref === 'OV_MEDIA_INTERNAL_URL');assert.strictEqual(listed.webhook_configured, false);assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Would go inside', body: 'hi' } })).status, 201);await t.app.locals.relay.drain();assert.deepStrictEqual(secretHits, [], 'nothing was sent to the internal URL');-- const d = t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();++ const d = await t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();assert.strictEqual(d.status, 'failed');assert.match(d.last_error, /not an allowed webhook variable/);assert.strictEqual((await map('DISCORD_WEBHOOK_GENERAL')).status, 201, 'the conventional names still work');@@@ -526,7 +495,7 @@ const { sqlTime } = require('../server/http/v1');process.env.EVENTS_URL = 'http://127.0.0.1:9';try {const t = await boot({ authority: 'community' });-- const st = t.app.locals.relay.status();++ const st = await t.app.locals.relay.status();assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled, t.app.locals.relayInbound], [false, false, false, null]);assert.match(st.events_worker.reason, /relay is off/);await t.close();@@@ -9,23 +9,26 @@ const assert = require('assert');const http = require('http');const express = require('express');const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { createRevocationStore } = require('openvibe-sdk/auth');--const { openDb } = require('../server/db');++const { createPgRevocationStore } = require('openvibe-sdk/auth');++const { testDb } = require('./helpers/db');const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');const { createViewerResolver } = require('../server/identity/viewer');--const SECRET = `whsec_${'ab'.repeat(32)}`;--const db = openDb(':memory:');--const revocations = createRevocationStore(db, { table: 'token_revocations' });--const app = express();--app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], revocations, log: { error() {}, log() {}, warn() {} } }).router);--const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', BEN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';--const at = Date.now() - 5000;--const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_type: 'network.user.token_valid_after', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),-- subject: { type: 'user', id: ANN }, actor: { type: 'user', id: ANN },-- payload: { subject: { type: 'user', id: ANN }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' }, ...over });++let SECRET, db, revocations, app, ANN, BEN, at, evt;(async () => {++ SECRET = `whsec_${'ab'.repeat(32)}`;++ db = await testDb();++ revocations = createPgRevocationStore(db, { table: 'token_revocations' });++ await revocations.load();++ app = express();++ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], revocations, log: { error() {}, log() {}, warn() {} } }).router);++ ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ BEN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++ at = Date.now() - 5000;++ evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_type: 'network.user.token_valid_after', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),++ subject: { type: 'user', id: ANN }, actor: { type: 'user', id: ANN },++ payload: { subject: { type: 'user', id: ANN }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' }, ...over });assert.ok(TOPICS.includes('network.user.token_valid_after'), 'Community subscribes to it');const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });const post = (event) => {@@@ -35,7 +38,7 @@ const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_const claimsOf = (subject, iatMs) => ({ sub: 7, subject_id: subject, username: 'someone', role: 'user', iat: Math.floor(iatMs / 1000), exp: Math.floor(Date.now() / 1000) + 3600 });let current = null;const viewers = createViewerResolver({ auth: { verify: async () => current }, config: { allowSandbox: false }, network: null, revocations });-- const who = async (claims) => { current = claims; return viewers.resolve({ headers: { authorization: 'Bearer opaque' }, cookies: {} }); };++ const who = async (claims) => { current = claims; return await viewers.resolve({ headers: { authorization: 'Bearer opaque' }, cookies: {} }); };try {assert.strictEqual((await who(claimsOf(ANN, at - 60000))).kind, 'user', 'before the event');let r = await post(evt());@@@ -9,105 +9,107 @@*/const assert = require('assert');const contracts = require('openvibe-contracts');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const events = require('../server/events');const pastes = require('../server/pastes/store');const forum = require('../server/forum/store');const { createSearchDocuments } = require('../server/search/documents');--const db = openDb(':memory:');--events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });--const index = () => db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => /^community\.index_document\./.test(e.event_type));--const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };--const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';--const docs = createSearchDocuments({ db });--const base = require('../server/config').baseUrl;++(async () => {++ const db = await testDb();++ events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });++ const index = async () => (await db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : r.envelope)).filter((e) => /^community\.index_document\./.test(e.event_type));++ const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };++ const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const docs = createSearchDocuments({ db });++ const base = require('../server/config').baseUrl;--let n = 0;--function check(name, fn) { fn(); n++; console.log(` ✓ ${name}`); }++ let n = 0;++ async function check(name, fn) { await fn(); n++; console.log(` ✓ ${name}`); }--db.prepare("INSERT OR IGNORE INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff')").run();--const general = db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get().id;--const staff = db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get().id;++ await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff') ON CONFLICT DO NOTHING").run();++ const general = (await db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get()).id;++ const staff = (await db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get()).id;--check('a public thread: its title, opening post and replies; unchanged means nothing sent', () => {-- const { thread } = forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: '**First** post with a [link](https://example.com)' });-- forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });-- assert.strictEqual(docs.publishThread(thread.id), 'sent');-- const ev = index()[0]; ok(ev);-- assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });-- assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');-- assert.strictEqual(ev.payload.canonical_url, `${base}/s/general/t/${thread.slug}`);-- assert.strictEqual(ev.payload.summary, 'First post with a link');-- assert.ok(ev.payload.body.includes('a reply body'));-- assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);-- assert.strictEqual(docs.publishThread(thread.id), 'unchanged');--});++ await check('a public thread: its title, opening post and replies; unchanged means nothing sent', async () => {++ const { thread } = await forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: '**First** post with a [link](https://example.com)' });++ await forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });++ assert.strictEqual(await docs.publishThread(thread.id), 'sent');++ const ev = (await index())[0]; ok(ev);++ assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });++ assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');++ assert.strictEqual(ev.payload.canonical_url, `${base}/s/general/t/${thread.slug}`);++ assert.strictEqual(ev.payload.summary, 'First post with a link');++ assert.ok(ev.payload.body.includes('a reply body'));++ assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);++ assert.strictEqual(await docs.publishThread(thread.id), 'unchanged');++ });--check('a staff thread is never sent; members-only gets one tombstone', () => {-- const { thread: hidden } = forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden' });-- assert.strictEqual(docs.publishThread(hidden.id), 'unchanged');-- const t = db.prepare("SELECT id FROM threads WHERE title = 'Hello there'").get();-- forum.setThreadMembersOnly(db, t.id, USR);-- assert.strictEqual(docs.publishThread(t.id), 'tombstone');-- assert.strictEqual(docs.publishThread(t.id), 'unchanged', 'a tombstone is sent once');-- const ev = index().at(-1); ok(ev);-- assert.deepStrictEqual(ev.payload, { type: 'thread', id: String(t.id), revision: 2 });-- assert.ok(!index().some((e) => JSON.stringify(e).includes('hidden')), 'nothing of a staff thread');--});++ await check('a staff thread is never sent; members-only gets one tombstone', async () => {++ const { thread: hidden } = await forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden' });++ assert.strictEqual(await docs.publishThread(hidden.id), 'unchanged');++ const t = await db.prepare("SELECT id FROM threads WHERE title = 'Hello there'").get();++ await forum.setThreadMembersOnly(db, t.id, USR);++ assert.strictEqual(await docs.publishThread(t.id), 'tombstone');++ assert.strictEqual(await docs.publishThread(t.id), 'unchanged', 'a tombstone is sent once');++ const ev = (await index()).at(-1); ok(ev);++ assert.deepStrictEqual(ev.payload, { type: 'thread', id: String(t.id), revision: 2 });++ assert.ok(!(await index()).some((e) => JSON.stringify(e).includes('hidden')), 'nothing of a staff thread');++ });--check('a crosspost is noindex', () => {-- const { thread } = forum.createThread(db, { space_id: general, title: 'Also here', author_subject: USR, body_markdown: 'see the original', crosspost_of: 1 });-- docs.publishThread(thread.id);-- const ev = index().at(-1); ok(ev);-- assert.deepStrictEqual(ev.payload.indexability, { decision: 'noindex', reasons: ['crosspost'] });--});++ await check('a crosspost is noindex', async () => {++ const { thread } = await forum.createThread(db, { space_id: general, title: 'Also here', author_subject: USR, body_markdown: 'see the original', crosspost_of: 1 });++ await docs.publishThread(thread.id);++ const ev = (await index()).at(-1); ok(ev);++ assert.deepStrictEqual(ev.payload.indexability, { decision: 'noindex', reasons: ['crosspost'] });++ });--check('pastes: public sent with its content, NSFW noindex, burn-after-read never, unlisted and deleted get a tombstone', () => {-- const p = pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'Quick sort', content: 'function sort(a) { return a; }', language: 'javascript', visibility: 'public' });-- const nsfw = pastes.insertPaste(db, { slug: 'red-owl-7', owner_subject: USR, origin: 'ai', type: 'paste', title: 'x', content: 'y', language: 'text', visibility: 'public', is_nsfw: 1 });-- const burn = pastes.insertPaste(db, { slug: 'blue-cat-9', owner_subject: USR, type: 'paste', title: 'secret', content: 'burns', visibility: 'public', burn_after_read: 1 });-- const row = (id) => db.prepare('SELECT * FROM pastes WHERE id = ?').get(id);-- assert.strictEqual(docs.publishPaste(row(p.id)), 'sent');-- assert.strictEqual(docs.publishPaste(row(nsfw.id)), 'sent');-- assert.strictEqual(docs.publishPaste(row(burn.id)), 'unchanged');-- const [a, b] = index().slice(-2); ok(a); ok(b);-- assert.strictEqual(a.payload.id, 'amber-fox-42'); assert.strictEqual(a.payload.canonical_url, `${base}/p/amber-fox-42`);-- assert.ok(a.payload.body.includes('function sort')); assert.strictEqual(a.payload.facets.syntax, 'javascript');-- assert.ok(!('language' in a.payload), 'a paste\'s syntax is not a human language');-- assert.deepStrictEqual(b.payload.indexability, { decision: 'noindex', reasons: ['sensitive'] });-- assert.strictEqual(b.payload.authorship, 'ai_generated');-- pastes.setVisibility(db, p.id, 'unlisted');-- assert.strictEqual(docs.publishPaste(row(p.id)), 'tombstone');-- ok(index().at(-1)); assert.deepStrictEqual(index().at(-1).payload, { type: 'paste', id: 'amber-fox-42', revision: 2 });--});++ await check('pastes: public sent with its content, NSFW noindex, burn-after-read never, unlisted and deleted get a tombstone', async () => {++ const p = await pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'Quick sort', content: 'function sort(a) { return a; }', language: 'javascript', visibility: 'public' });++ const nsfw = await pastes.insertPaste(db, { slug: 'red-owl-7', owner_subject: USR, origin: 'ai', type: 'paste', title: 'x', content: 'y', language: 'text', visibility: 'public', is_nsfw: 1 });++ const burn = await pastes.insertPaste(db, { slug: 'blue-cat-9', owner_subject: USR, type: 'paste', title: 'secret', content: 'burns', visibility: 'public', burn_after_read: 1 });++ const row = async (id) => await db.prepare('SELECT * FROM pastes WHERE id = ?').get(id);++ assert.strictEqual(await docs.publishPaste(await row(p.id)), 'sent');++ assert.strictEqual(await docs.publishPaste(await row(nsfw.id)), 'sent');++ assert.strictEqual(await docs.publishPaste(await row(burn.id)), 'unchanged');++ const [a, b] = (await index()).slice(-2); ok(a); ok(b);++ assert.strictEqual(a.payload.id, 'amber-fox-42'); assert.strictEqual(a.payload.canonical_url, `${base}/p/amber-fox-42`);++ assert.ok(a.payload.body.includes('function sort')); assert.strictEqual(a.payload.facets.syntax, 'javascript');++ assert.ok(!('language' in a.payload), 'a paste\'s syntax is not a human language');++ assert.deepStrictEqual(b.payload.indexability, { decision: 'noindex', reasons: ['sensitive'] });++ assert.strictEqual(b.payload.authorship, 'ai_generated');++ await pastes.setVisibility(db, p.id, 'unlisted');++ assert.strictEqual(await docs.publishPaste(await row(p.id)), 'tombstone');++ ok((await index()).at(-1)); assert.deepStrictEqual((await index()).at(-1).payload, { type: 'paste', id: 'amber-fox-42', revision: 2 });++ });--check('the scan finds a paste changed since the last scan; the refresh removes one that is gone', () => {-- const now = Date.now();-- docs.scan({ now });-- const before = index().length;-- const p = pastes.insertPaste(db, { slug: 'green-elk-3', owner_subject: USR, type: 'paste', title: 'Later', content: 'later body', visibility: 'public' });-- db.prepare("UPDATE pastes SET created_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(p.id);-- docs.scan({ now: now + 60_000 });-- assert.ok(index().slice(before).some((e) => e.payload.id === 'green-elk-3' && e.event_type === 'community.index_document.upserted'));-- db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);-- docs.refresh();-- const last = index().at(-1); ok(last);-- assert.deepStrictEqual(last.payload, { type: 'paste', id: 'green-elk-3', revision: 2 });--});++ await check('the scan finds a paste changed since the last scan; the refresh removes one that is gone', async () => {++ const now = Date.now();++ await docs.scan({ now });++ const before = (await index()).length;++ const p = await pastes.insertPaste(db, { slug: 'green-elk-3', owner_subject: USR, type: 'paste', title: 'Later', content: 'later body', visibility: 'public' });++ await db.prepare("UPDATE pastes SET created_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(p.id);++ await docs.scan({ now: now + 60_000 });++ assert.ok((await index()).slice(before).some((e) => e.payload.id === 'green-elk-3' && e.event_type === 'community.index_document.upserted'));++ await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);++ await docs.refresh();++ const last = (await index()).at(-1); ok(last);++ assert.deepStrictEqual(last.payload, { type: 'paste', id: 'green-elk-3', revision: 2 });++ });--check('a slug starting with a dash is indexed as paste_<id> (a document id starts with a letter or digit)', () => {-- const p = pastes.insertPaste(db, { slug: '-wgXuYn0', owner_subject: USR, type: 'paste', title: 'Dash', content: 'dash body', visibility: 'public' });-- db.prepare("INSERT INTO search_doc_pushes (type, id, hash, revision) VALUES ('paste', '-wgXuYn0', 'x', 1)").run();-- assert.strictEqual(docs.publishPaste(db.prepare('SELECT * FROM pastes WHERE id = ?').get(p.id)), 'sent');-- const ev = index().at(-1); ok(ev);-- assert.strictEqual(ev.payload.id, `paste_${p.id}`); assert.strictEqual(ev.payload.canonical_url, `${base}/p/-wgXuYn0`);-- const r = contracts.validate('search.index-document@1', ev.payload); assert.ok(r.valid, JSON.stringify(r.errors));-- const before = index().length;-- docs.refresh();-- assert.ok(!db.prepare("SELECT 1 FROM search_doc_pushes WHERE id = '-wgXuYn0'").get(), 'the unacceptable id is forgotten');-- assert.ok(!index().slice(before).some((e) => e.payload.id === '-wgXuYn0'), 'and no tombstone is sent for it');--});++ await check('a slug starting with a dash is indexed as paste_<id> (a document id starts with a letter or digit)', async () => {++ const p = await pastes.insertPaste(db, { slug: '-wgXuYn0', owner_subject: USR, type: 'paste', title: 'Dash', content: 'dash body', visibility: 'public' });++ await db.prepare("INSERT INTO search_doc_pushes (type, id, hash, revision) VALUES ('paste', '-wgXuYn0', 'x', 1)").run();++ assert.strictEqual(await docs.publishPaste(await db.prepare('SELECT * FROM pastes WHERE id = ?').get(p.id)), 'sent');++ const ev = (await index()).at(-1); ok(ev);++ assert.strictEqual(ev.payload.id, `paste_${p.id}`); assert.strictEqual(ev.payload.canonical_url, `${base}/p/-wgXuYn0`);++ const r = contracts.validate('search.index-document@1', ev.payload); assert.ok(r.valid, JSON.stringify(r.errors));++ const before = (await index()).length;++ await docs.refresh();++ assert.ok(!await db.prepare("SELECT 1 FROM search_doc_pushes WHERE id = '-wgXuYn0'").get(), 'the unacceptable id is forgotten');++ assert.ok(!(await index()).slice(before).some((e) => e.payload.id === '-wgXuYn0'), 'and no tombstone is sent for it');++ });--events._reset();--console.log(`search documents: ${n} checks passed`);++ events._reset();++ console.log(`search documents: ${n} checks passed`);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -69,26 +69,26 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];const res = await fetch(t.base + path, { method, headers: h, body: payload, redirect: 'manual' });return { status: res.status, text: await res.text(), json() { return JSON.parse(this.text); } };}-- const snapshot = () => TABLES.map((tb) => `${tb}:${JSON.stringify(t.db.prepare(`SELECT * FROM ${tb} ORDER BY rowid`).all())}`).join('\n');++ const snapshot = async () => (await Promise.all(TABLES.map(async (tb) => `${tb}:${JSON.stringify(await t.db.prepare(`SELECT * FROM ${tb} ORDER BY ${tb}::text`).all())}`))).join('\n');const ok = (r, what) => { assert.ok(r.status >= 200 && r.status < 300, `${what}: ${r.status} ${r.text.slice(0, 300)}`); return r.status === 204 ? null : r.json(); };const imageForm = (field, extra = {}) => { const f = new FormData(); for (const [k, v] of Object.entries(extra)) f.append(k, v); f.append(field, new Blob([PNG], { type: 'image/png' }), 'x.png'); return f; };/** Each persona's attempt is refused (401/403/404, or the route's documented refusal) and changes nothing. */async function refused(label, method, path, personas, opts = {}, statuses = [401, 403, 404]) {for (const name of personas) {-- const before = snapshot();++ const before = await snapshot();const o = typeof opts === 'function' ? opts() : opts;const r = await send(method, path, { ...as[name], ...o, headers: { ...(as[name].headers || {}), ...(o.headers || {}) } });assert.ok(statuses.includes(r.status), `${label} as ${name}: ${r.status} ${r.text.slice(0, 200)}`);-- assert.strictEqual(snapshot(), before, `${label} as ${name}: refused (${r.status}) but the database changed`);++ assert.strictEqual(await snapshot(), before, `${label} as ${name}: refused (${r.status}) but the database changed`);}}/** The owner (or staff) may: the route works, and it changes the database. */async function allowed(label, method, path, name, opts = {}) {-- const before = snapshot();++ const before = await snapshot();const r = await send(method, path, { ...as[name], ...opts, headers: { ...(as[name].headers || {}), ...(opts.headers || {}) } });const out = ok(r, `${label} as ${name}`);-- assert.notStrictEqual(snapshot(), before, `${label} as ${name}: answered ${r.status} but nothing changed`);++ assert.notStrictEqual(await snapshot(), before, `${label} as ${name}: answered ${r.status} but nothing changed`);return out;}@@@ -104,8 +104,8 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];const cthread = ok(await send('POST', '/api/v1/comments/threads/resolve', { who: alex, json: { ref: { service: 'community', type: 'paste', id: pub.slug } } }), 'comment thread').thread;const typed = ok(await send('POST', `/api/v1/comments/threads/${cthread.id}/comments`, { who: alex, json: { message: 'alex typed comment' } }), 'typed comment').comment;const upload = ok(await send('POST', '/api/v1/spaces/general/attachments', { who: alex, body: imageForm('file') }), 'alex upload').attachment;-- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(alex.subject_id);-- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(cora.subject_id);++ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(alex.subject_id);++ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(cora.subject_id);ok(await send('PUT', '/api/v1/spaces/general/chat-room', { who: alex, json: { room: 'alex-room' } }), 'alex attaches a room to his space');const liveItem = ok(await send('POST', '/api/v1/pulse/items', { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }), json: { ref: { service: 'live', type: 'stream', id: '9' }, title: 'alex is live', url: 'https://openvibe.live/@alex' } }), 'live pulse item');assert.ok(liveItem.item);@@@ -146,7 +146,7 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];await check('likes and votes are always the caller\'s own; an app cannot name alex', async () => {await allowed('sam likes alex\'s paste', 'POST', `/api/pastes/${pub.slug}/like`, 'sam');-- const likers = t.db.prepare('SELECT subject_id FROM paste_likes pl JOIN pastes p ON p.id = pl.paste_id WHERE p.slug = ?').all(pub.slug).map((r) => r.subject_id);++ const likers = (await t.db.prepare('SELECT subject_id FROM paste_likes pl JOIN pastes p ON p.id = pl.paste_id WHERE p.slug = ?').all(pub.slug)).map((r) => r.subject_id);assert.deepStrictEqual(likers, [sam.subject_id]);await refused('an app for sam likes as alex', 'POST', `/api/pastes/${pub.slug}/like`, ['an app for sam'], { headers: { 'x-ov-subject': alex.subject_id } });await refused('an app for sam votes as alex', 'POST', `/api/v1/spaces/general/threads/${thread.thread.slug}/votes`, ['an app for sam'], { headers: { 'x-ov-subject': alex.subject_id }, json: { value: 1 } });@@@ -132,13 +132,13 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR}}// Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.-- const before = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;++ const before = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;for (const [method, suffix, json] of [['POST', '/like'], ['POST', '/copy'], ['POST', '/fork', {}], ['POST', '/comments', { message: 'hi there' }], ['DELETE', `/comments/${privComment.comment.id}`]]) {const a = await send(method, `/api/pastes/${priv.slug}${suffix}`, { ...p, json }), b = await send(method, `/api/pastes/${UNKNOWN_SLUG}${suffix}`, { ...p, json });assert.strictEqual(a.status, b.status, `${method} ${suffix} as ${name}: ${a.status} vs ${b.status}`);assert.ok(!allMarkers.some((m) => a.text.includes(m)), `${method} ${suffix} as ${name} shows a marker`);}-- const after = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;++ const after = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;assert.strictEqual(after, before, `a refused write changed something (${name})`);// A browser cannot open a comment thread on it either (it looks missing).if (p.who) {@@@ -204,7 +204,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR});await check('events, Pulse and search documents: nothing private is public; comment events are public only for items Community knows are public', async () => {-- const outbox = t.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));++ const outbox = (await t.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : r.envelope));const text = JSON.stringify(outbox);for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!text.includes(m), `an event carries ${m}`);const pub = (e) => e.visibility === 'public';@@@ -232,7 +232,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVRconst docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);-- const pulse = JSON.stringify(t.db.prepare('SELECT * FROM pulse_items').all());++ const pulse = JSON.stringify(await t.db.prepare('SELECT * FROM pulse_items').all());assert.ok(pulse.includes('Open thread'), 'Pulse took the public thread');for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!pulse.includes(m), `Pulse holds ${m}`);});@@@ -143,7 +143,7 @@ function formsOf(value) {assert.strictEqual(r.status, 200, `a delivery signed with ${secret === EVENTS_SECRETS[0] ? 'the current' : 'the previous'} secret: ${r.text}`);}const visitor = `ip:${crypto.createHmac('sha256', ENV.VIEW_HASH_SECRET).update('203.0.113.9').digest('hex').slice(0, 32)}`;-- assert.ok(t.db.prepare('SELECT 1 FROM paste_visits WHERE visitor = ?').get(visitor), 'anonymous views are keyed by an HMAC under VIEW_HASH_SECRET');++ assert.ok(await t.db.prepare('SELECT 1 FROM paste_visits WHERE visitor = ?').get(visitor), 'anonymous views are keyed by an HMAC under VIEW_HASH_SECRET');});// What must never come back.@@@ -268,12 +268,12 @@ function formsOf(value) {});await check('the events outbox, the relay\'s recorded errors and the service\'s own tables hold no secret', async () => {-- const outbox = t.db.prepare('SELECT envelope FROM event_outbox').all();++ const outbox = await t.db.prepare('SELECT envelope FROM event_outbox').all();assert.ok(outbox.length >= 5, `events were queued (${outbox.length})`);const where = [];-- for (const row of outbox) { const f = leaksIn(row.envelope); if (f.length) where.push(`outbox: ${f.join(', ')}`); }++ for (const row of outbox) { const f = leaksIn(typeof row.envelope === 'string' ? row.envelope : JSON.stringify(row.envelope)); if (f.length) where.push(`outbox: ${f.join(', ')}`); }for (const table of ['relay_deliveries', 'relay_mappings', 'relay_inbound_failures', 'pastes', 'paste_comments', 'posts', 'threads', 'comments', 'comment_threads', 'pulse_items', 'search_doc_pushes']) {-- for (const row of t.db.prepare(`SELECT * FROM ${table}`).all()) { const f = leaksIn(JSON.stringify(row)); if (f.length) where.push(`${table}: ${f.join(', ')}`); }++ for (const row of await t.db.prepare(`SELECT * FROM ${table}`).all()) { const f = leaksIn(JSON.stringify(row)); if (f.length) where.push(`${table}: ${f.join(', ')}`); }}assert.deepStrictEqual(where, []);});@@@ -168,7 +168,7 @@ function rawGet(base, rawPath) {assert.strictEqual((await send('POST', '/api/pastes/screenshot', { body: f })).status, 201);const cth = JSON.parse((await send('POST', '/api/v1/comments/threads/resolve', { json: { ref: { service: 'community', type: 'paste', id: JSON.parse(p.text).slug } } })).text).thread;await send('POST', `/api/v1/comments/threads/${cth.id}/comments`, { json: { message: ` ${urls[4]}` } });-- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(u.subject_id);++ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(u.subject_id);await send('PUT', '/api/v1/spaces/general/chat-room', { json: { room: urls[7] } });const map = await send('POST', '/api/v1/relay/mappings', { cookie: bossJwt, json: { space: 'general', webhook_url_ref: `${C}/hook` } });assert.strictEqual(map.status, 400, 'a mapping names a variable, never a URL');@@@ -58,7 +58,7 @@ const { boot, check, done } = require('./helpers/app');await check('an app token naming a victim in X-OV-Subject cannot edit their forum post or post as them', async () => {const r = await call(`/api/v1/posts/${alexPostId}`, { method: 'PUT', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { body: 'defaced' } });assert.notStrictEqual(r.status, 200, `post edited by a third-party app: ${r.text}`);-- const post = t.db.prepare('SELECT body_markdown FROM posts WHERE id = ?').get(alexPostId);++ const post = await t.db.prepare('SELECT body_markdown FROM posts WHERE id = ?').get(alexPostId);assert.strictEqual(post.body_markdown, 'original words');const p = await call('/api/pastes', { method: 'POST', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'written as alex' } });assert.strictEqual(p.status, 403, p.text);@@@ -167,14 +167,14 @@ const { boot, check, done } = require('./helpers/app');const pub = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'public words' } });assert.match(pub.json().slug, SHORT, 'public pastes are listed anyway: short slug');// A paste made before this change (short slug, unlisted) still opens by its slug.-- t.db.prepare("INSERT INTO pastes (slug, type, title, content, language, visibility) VALUES ('calm-otter-42', 'paste', 'old', 'old unlisted words', 'text', 'unlisted')").run();++ await t.db.prepare("INSERT INTO pastes (slug, type, title, content, language, visibility) VALUES ('calm-otter-42', 'paste', 'old', 'old unlisted words', 'text', 'unlisted')").run();const old = await call('/api/pastes/calm-otter-42');assert.strictEqual(old.status, 200, old.text);assert.strictEqual(old.json().paste.content, 'old unlisted words');assert.strictEqual((await call('/p/calm-otter-42/raw')).status, 200);// The secret part is really random.const { generateSlug } = require('../server/pastes/store');-- const many = new Set(Array.from({ length: 2000 }, () => generateSlug(t.db, { secret: true }).split('-')[2]));++ const many = new Set(Array.from({ length: 2000 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2]));assert.strictEqual(many.size, 2000);});@@@ -24,7 +24,7 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {cwd: path.join(__dirname, '..'),env: {-- ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test', COMMUNITY_DB_PATH: path.join(dir, 'community.db'),++ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test', COMMUNITY_PGLITE_DIR: path.join(dir, 'pglite'), DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '',EVENTS_URL: 'http://127.0.0.1:9', OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9',OV_OAUTH_CLIENT_SECRET: 'test-secret-not-real', COMMUNITY_EVENTS_SECRET: 'e'.repeat(40), DISCORD_RELAY_ENABLED: '1',DISCORD_RELAY_INBOUND: 'on', DISCORD_BOT_TOKEN: 'not-a-real-bot-token', DISCORD_GATEWAY_URL: 'ws://127.0.0.1:9/?v=10&encoding=json',@@@ -21,8 +21,8 @@ const { boot, check, done } = require('./helpers/app');const unl = (await svc.createText(asAlex, { title: 'secret notes', content: 'unlisted <script>alert(1)</script>', visibility: 'unlisted' })).slug;const priv = (await svc.createText(asAlex, { title: 'private thing', content: 'private', visibility: 'private' })).slug;const burn = (await svc.createText(asAlex, { title: 'burn me', content: 'once only', burn_after_read: true })).slug;-- t.db.prepare('UPDATE pastes SET views = 900 WHERE slug = ?').run(nginx);-- t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, screenshot_url) VALUES ('fair-moon-23', ?, 'screenshot', 'Desktop shot', 'my desktop', 'https://openvibe.media/f/abc-desk.png')").run(alex.subject_id);++ await t.db.prepare('UPDATE pastes SET views = 900 WHERE slug = ?').run(nginx);++ await t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, screenshot_url) VALUES ('fair-moon-23', ?, 'screenshot', 'Desktop shot', 'my desktop', 'https://openvibe.media/f/abc-desk.png')").run(alex.subject_id);await check('home: latest + most viewed come from the store; nothing hidden; Live is never asked', async () => {const r = await t.get('/');@@@ -42,7 +42,7 @@ const { boot, check, done } = require('./helpers/app');const ld = r.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/g).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));assert.strictEqual(ld.find((o) => o['@type'] === 'Article').author.name, 'Alex');has(r.text, 'https://openvibe.media/avatars/alex.png');-- assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(js).views, 1);++ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(js)).views, 1);assert.ok(!r.text.includes(`data-delete="${js}"`), 'no delete button for visitors');});@@@ -68,12 +68,12 @@ const { boot, check, done } = require('./helpers/app');});await check('download reads the store without a view; screenshots use the stored Media URL', async () => {-- const before = t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views;++ const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py)).views;const dl = await t.get(`/p/${py}/download`);assert.strictEqual(dl.status, 200);assert.strictEqual(dl.headers.get('content-disposition'), `attachment; filename="${py}.py"`);assert.ok(dl.text.startsWith('def add'));-- assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views, before);++ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py)).views, before);const shot = await t.get('/p/fair-moon-23');has(shot.text, '<meta property="og:image" content="https://openvibe.media/f/abc-desk.png">');const dls = await t.get('/p/fair-moon-23/download');@@@ -104,7 +104,7 @@ const { boot, check, done } = require('./helpers/app');const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });assert.strictEqual(post.status, 303);const slug = post.headers.get('location').replace('/p/', '');-- const row = t.db.prepare('SELECT owner_subject, visibility FROM pastes WHERE slug = ?').get(slug);++ const row = await t.db.prepare('SELECT owner_subject, visibility FROM pastes WHERE slug = ?').get(slug);assert.deepStrictEqual(row, { owner_subject: alex.subject_id, visibility: 'private' });const anonBody = new URLSearchParams({ content: 'hi' }).toString();const ip = '203.0.113.200';@@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/app');const net = t.network;const { createViewerResolver } = require('../server/identity/viewer');const viewers = createViewerResolver({ auth: t.app.locals.auth, config: require('../server/config'), network: null });-- const resolve = (token) => viewers.resolve({ headers: { authorization: `Bearer ${token}` }, get: () => undefined, cookies: {} });++ const resolve = async (token) => await viewers.resolve({ headers: { authorization: `Bearer ${token}` }, get: () => undefined, cookies: {} });const tok = (claims) => net.sign({ id: Math.floor(Math.random() * 1e6), subject_id: ids.newId('user'), username: 'x', display_name: 'X', ...claims });await check('a global moderator is paste and discussion staff', async () => {@@@ -6,19 +6,12 @@*/const assert = require('assert');const { ids } = require('openvibe-contracts');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const store = require('../server/pastes/store');const { createPasteService, PasteError } = require('../server/pastes/service');const { check, done } = require('./helpers/app');--const db = openDb(':memory:');--const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });----const alice = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };--const bob = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };--const mod = { kind: 'user', subject: ids.newId('user'), staff: true, origin: 'user' };--const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };--store.upsertProjection(db, { subject_id: alice.subject, username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.media/a.png', profile_color: '#f0a' });++let db, svc, alice, bob, mod, anon;async function rejects(p, status, message) {try { await p; } catch (err) {@@@ -31,6 +24,13 @@ async function rejects(p, status, message) {}(async () => {++ db = await testDb();++ svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });++ alice = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };++ bob = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };++ mod = { kind: 'user', subject: ids.newId('user'), staff: true, origin: 'user' };++ anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };++ await store.upsertProjection(db, { subject_id: alice.subject, username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.media/a.png', profile_color: '#f0a' });let pub, unl, priv;await check('create: slug style, trimmed content, language detection, owner, Media response shape', async () => {@@@ -81,14 +81,14 @@ async function rejects(p, status, message) {assert.strictEqual((await svc.get(mod, priv, { noView: true })).paste.visibility, 'private');// Private pastes can't be forked, liked, copied or commented by others either.await rejects(svc.fork(bob, priv), 404);-- await rejects(Promise.resolve().then(() => svc.like(bob, priv)), 404);-- await rejects(Promise.resolve().then(() => svc.copy(anon, priv)), 404);++ await rejects(Promise.resolve().then(async () => await svc.like(bob, priv)), 404);++ await rejects(Promise.resolve().then(async () => await svc.copy(anon, priv)), 404);await rejects(svc.comments(anon, priv), 404);});await check('list: preview content (300 chars, null for screenshots), filters, owner view via username', async () => {const long = await svc.createText(bob, { title: 'long one', content: 'L'.repeat(1000) });-- store.upsertProjection(db, { subject_id: bob.subject, username: 'bob', display_name: 'Bob' });++ await store.upsertProjection(db, { subject_id: bob.subject, username: 'bob', display_name: 'Bob' });const list = await svc.list(anon, { search: 'long one' });assert.strictEqual(list.total, 1);assert.strictEqual(list.pastes[0].content.length, 300);@@@ -99,7 +99,7 @@ async function rejects(p, status, message) {assert.ok(!theirs.pastes.some((p) => p.slug === priv || p.slug === unl), 'others never do');assert.deepStrictEqual(await svc.list(anon, { username: 'nobody' }), { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });const wildcard = await svc.list(anon, { search: '%' });-- assert.strictEqual(wildcard.total, 0, 'LIKE wildcards are literal');++ assert.strictEqual(wildcard.total, 0, 'ILIKE wildcards are literal');assert.strictEqual((await svc.list(anon, { limit: 1 })).pastes.length, 1);assert.strictEqual(long.paste.username, null, 'no projection yet at creation → no name (not a crash)');});@@@ -125,7 +125,7 @@ async function rejects(p, status, message) {const p = (await svc.get(anon, slug, { ip: '198.51.100.3' })).paste;assert.strictEqual(p.views, 3);assert.strictEqual(p.unique_views, 3);-- const visits = db.prepare('SELECT visitor FROM paste_visits WHERE paste_id = ?').all(p.id).map((r) => r.visitor);++ const visits = (await db.prepare('SELECT visitor FROM paste_visits WHERE paste_id = ?').all(p.id)).map((r) => r.visitor);assert.ok(visits.every((v) => !v.includes('198.51.100')), 'addresses are hashed, never stored');});@@@ -137,13 +137,13 @@ async function rejects(p, status, message) {assert.strictEqual(first.paste.burn_after_read, true);await rejects(svc.get(anon, slug, { ip: '203.0.113.5' }), 410, 'This paste has been burned after reading.');await rejects(svc.get(alice, slug), 404);-- const row = db.prepare('SELECT content, deleted_at FROM pastes WHERE slug = ?').get(slug);++ const row = await db.prepare('SELECT content, deleted_at FROM pastes WHERE slug = ?').get(slug);assert.strictEqual(row.content, null, 'content scrubbed');assert.ok(row.deleted_at);// Raw text: Media's rule — the first read serves it, the next one burns it.const raw = (await svc.createText(alice, { content: 'raw secret', burn_after_read: true })).slug;-- assert.strictEqual(svc.raw(bob, raw).content, 'raw secret');-- await rejects(Promise.resolve().then(() => svc.raw(bob, raw)), 410);++ assert.strictEqual((await svc.raw(bob, raw)).content, 'raw secret');++ await rejects(Promise.resolve().then(async () => await svc.raw(bob, raw)), 410);});await check('fork: text only, "Fork of …", public, forked_from set; like toggles; copy counts', async () => {@@@ -152,16 +152,16 @@ async function rejects(p, status, message) {assert.strictEqual(f.paste.visibility, 'public');assert.strictEqual(f.paste.owner_subject, bob.subject);assert.strictEqual(f.paste.forked_from, (await svc.get(anon, pub, { noView: true })).paste.id);-- db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url) VALUES ('shot-only-1', 'screenshot', 'S', 'https://openvibe.media/f/x.png')").run();++ await db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url) VALUES ('shot-only-1', 'screenshot', 'S', 'https://openvibe.media/f/x.png')").run();await rejects(svc.fork(bob, 'shot-only-1'), 400, 'Only text pastes can be forked');-- assert.deepStrictEqual(svc.like(bob, pub), { liked: true, likes: 1 });-- assert.deepStrictEqual(svc.like(alice, pub), { liked: true, likes: 2 });-- assert.deepStrictEqual(svc.like(bob, pub), { liked: false, likes: 1 });-- await rejects(Promise.resolve().then(() => svc.like(anon, pub)), 401, 'Authentication required');++ assert.deepStrictEqual(await svc.like(bob, pub), { liked: true, likes: 1 });++ assert.deepStrictEqual(await svc.like(alice, pub), { liked: true, likes: 2 });++ assert.deepStrictEqual(await svc.like(bob, pub), { liked: false, likes: 1 });++ await rejects(Promise.resolve().then(async () => await svc.like(anon, pub)), 401, 'Authentication required');assert.strictEqual((await svc.get(alice, pub, { noView: true })).paste.liked, true);assert.strictEqual((await svc.get(bob, pub, { noView: true })).paste.liked, false);-- assert.deepStrictEqual(svc.copy(anon, pub), { copies: 1 });-- assert.deepStrictEqual(svc.copy(anon, pub), { copies: 2 });++ assert.deepStrictEqual(await svc.copy(anon, pub), { copies: 1 });++ assert.deepStrictEqual(await svc.copy(anon, pub), { copies: 2 });});await check('comments: anonymous with a cleaned name, replies one level deep, newest first, delete rules', async () => {@@@ -185,11 +185,11 @@ async function rejects(p, status, message) {assert.strictEqual(list.comments[0].replies[0].username, 'alice');assert.strictEqual(list.comments[0].replies[0].display_name, 'Alice');// Delete: author, paste owner or staff; everyone else 403; anonymous 401.-- await rejects(Promise.resolve().then(() => svc.deleteComment(anon, pub, b.id)), 401);-- await rejects(Promise.resolve().then(() => svc.deleteComment({ kind: 'user', subject: ids.newId('user'), staff: false }, pub, b.id)), 403, 'Not authorized to delete this comment');-- assert.deepStrictEqual(svc.deleteComment(bob, pub, b.id), { message: 'Comment deleted' });-- assert.deepStrictEqual(svc.deleteComment(alice, pub, a.id), { message: 'Comment deleted' }, 'paste owner');-- await rejects(Promise.resolve().then(() => svc.deleteComment(alice, pub, 424242)), 404, 'Comment not found');++ await rejects(Promise.resolve().then(async () => await svc.deleteComment(anon, pub, b.id)), 401);++ await rejects(Promise.resolve().then(async () => await svc.deleteComment({ kind: 'user', subject: ids.newId('user'), staff: false }, pub, b.id)), 403, 'Not authorized to delete this comment');++ assert.deepStrictEqual(await svc.deleteComment(bob, pub, b.id), { message: 'Comment deleted' });++ assert.deepStrictEqual(await svc.deleteComment(alice, pub, a.id), { message: 'Comment deleted' }, 'paste owner');++ await rejects(Promise.resolve().then(async () => await svc.deleteComment(alice, pub, 424242)), 404, 'Comment not found');assert.strictEqual((await svc.comments(anon, pub)).total, 1, 'deleted comments are hidden (the reply remains)');});@@@ -215,15 +215,15 @@ async function rejects(p, status, message) {// Anonymous callers aren't limited here (the per-address limiter in api.js is).await strict.createText(anon, { content: 'x' });await strict.createText(anon, { content: 'y' });-- assert.deepStrictEqual(Object.keys(capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);-- assert.strictEqual(capped.config(dave).todayCount, 1);++ assert.deepStrictEqual(Object.keys(await capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);++ assert.strictEqual((await capped.config(dave)).todayCount, 1);});await check('update: owner or staff; versions appended; pinned staff-only; errors', async () => {await rejects(svc.update(anon, pub, { title: 'x' }), 401, 'Authentication required');await rejects(svc.update(bob, pub, { title: 'x' }), 403, 'Not authorized for this paste');await rejects(svc.update(bob, priv, { title: 'x' }), 404, 'Paste not found');-- await rejects(Promise.resolve().then(() => svc.remove(bob, priv)), 404, 'Paste not found');++ await rejects(Promise.resolve().then(async () => await svc.remove(bob, priv)), 404, 'Paste not found');await rejects(svc.update(alice, pub, {}), 400, 'Nothing to update');await rejects(svc.update(alice, pub, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Too large');const u = await svc.update(alice, pub, { content: 'def f():\n pass', pinned: true });@@@ -233,46 +233,46 @@ async function rejects(p, status, message) {const m = await svc.update(mod, pub, { title: 'Renamed', pinned: true });assert.strictEqual(m.paste.pinned, true);assert.strictEqual(m.paste.revision, 3);-- const v = svc.versions(alice, pub);++ const v = await svc.versions(alice, pub);assert.deepStrictEqual(v.versions.map((x) => x.revision), [1, 2, 3]);assert.strictEqual(v.versions[0].content, 'const x = 1;', 'original snapshotted');assert.strictEqual(v.versions[1].edited_by, alice.subject);assert.strictEqual(v.versions[2].edited_by, mod.subject);await svc.update(alice, pub, { visibility: 'unlisted' });-- assert.strictEqual(svc.versions(alice, pub).revision, 3, 'visibility changes are not text revisions');-- await rejects(Promise.resolve().then(() => svc.versions(bob, unl)), 403);++ assert.strictEqual((await svc.versions(alice, pub)).revision, 3, 'visibility changes are not text revisions');++ await rejects(Promise.resolve().then(async () => await svc.versions(bob, unl)), 403);});await check('delete: owner or staff; soft delete keeps the slug reserved; gone from reads and lists', async () => {-- await rejects(Promise.resolve().then(() => svc.remove(bob, unl)), 403);-- assert.deepStrictEqual(svc.remove(alice, unl), { success: true });++ await rejects(Promise.resolve().then(async () => await svc.remove(bob, unl)), 403);++ assert.deepStrictEqual(await svc.remove(alice, unl), { success: true });await rejects(svc.get(alice, unl), 404);-- assert.deepStrictEqual(svc.remove(mod, priv), { success: true }, 'staff');-- const row = db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);++ assert.deepStrictEqual(await svc.remove(mod, priv), { success: true }, 'staff');++ const row = await db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);assert.ok(row && row.deleted_at && row.content === null);assert.ok(!(await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 200 })).pastes.some((p) => p.slug === unl));});await check('staff tools: stats, forks, bulk, AI write-back', async () => {-- const stats = svc.stats().stats;++ const stats = (await svc.stats()).stats;assert.ok(stats.total > 0 && stats.forks >= 1 && stats.textPastes > 0);-- assert.ok(svc.forks({}).forks.length >= 1);-- const b = svc.bulk({ slugs: [pub, 'no-such-slug'], action: 'unlisted' });++ assert.ok((await svc.forks({})).forks.length >= 1);++ const b = await svc.bulk({ slugs: [pub, 'no-such-slug'], action: 'unlisted' });assert.deepStrictEqual(b, { done: 1, skipped: 1 });-- assert.throws(() => svc.bulk({ slugs: [], action: 'delete' }), /No slugs provided/);-- assert.throws(() => svc.bulk({ slugs: [pub], action: 'nuke' }), /Invalid action/);++ await assert.rejects(async () => await svc.bulk({ slugs: [], action: 'delete' }), /No slugs provided/);++ await assert.rejects(async () => await svc.bulk({ slugs: [pub], action: 'nuke' }), /Invalid action/);const ai = await svc.setAi(mod, pub, { ai_summary: 's'.repeat(3000), ai_tags: ['a', 'b'] });assert.strictEqual(ai.ok, true);assert.strictEqual(ai.paste.ai_summary.length, 2000);assert.strictEqual(ai.paste.ai_tags, '["a","b"]');assert.ok(ai.paste.ai_analyzed_at);-- const del = svc.deleteForks();++ const del = await svc.deleteForks();assert.ok(del.deleted >= 1 && del.success === true);});await check('generateSlug never reuses a slug, even a deleted one', async () => {-- const seen = new Set(db.prepare('SELECT slug FROM pastes').all().map((r) => r.slug));-- for (let i = 0; i < 200; i++) { const s = store.generateSlug(db); assert.ok(!seen.has(s)); }++ const seen = new Set((await db.prepare('SELECT slug FROM pastes').all()).map((r) => r.slug));++ for (let i = 0; i < 200; i++) { const s = await store.generateSlug(db); assert.ok(!seen.has(s)); }});db.close();@@@ -10,34 +10,38 @@ const assert = require('assert');const http = require('http');const express = require('express');const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');--const SECRET = `whsec_${'ef'.repeat(32)}`;--const db = openDb(':memory:');--const app = express();--app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], log: { error() {}, log() {}, warn() {} } }).router);--const KEEP = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1', FOLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2', OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';--const MERGE = 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4';--const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN09', event_type: 'network.subject.merged', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),-- subject: { type: 'user', id: KEEP }, actor: { type: 'user', id: KEEP },-- payload: { merge_id: MERGE, from: FOLD, into: KEEP, merged_at: new Date().toISOString(), initiated_by: 'person' }, ...over });++let SECRET, db, app, KEEP, FOLD, OTHER, MERGE, evt;// What the folded-in account left in Community.--db.prepare("INSERT INTO pastes (id, slug, owner_subject, likes) VALUES (1, 'p1', ?, 2), (2, 'p2', ?, 1)").run(FOLD, OTHER);--db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (1, ?), (1, ?), (2, ?)').run(FOLD, KEEP, FOLD); // paste 1: both liked--db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) VALUES (1, 'live', 'vod', '9')").run();--db.prepare("INSERT INTO comments (id, thread_id, message, author_subject, score, upvotes, downvotes) VALUES (1, 1, 'hi', ?, 2, 2, 0)").run(OTHER);--db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (1, ?, 1), (1, ?, 1)').run(FOLD, KEEP);--db.prepare("INSERT INTO spaces (id, slug, name) VALUES (901, 'merge-test', 'Merge test')").run();--db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) VALUES (1, 901, 't1', 'T1', ?, 1)").run(FOLD);--db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (1, ?, 1)').run(FOLD);--db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject) VALUES (1, 1, 'hello', ?)").run(FOLD);--db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (1, ?, 'fire'), (1, ?, 'heart')").run(FOLD, KEEP);--db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 5, '2026-09-27')").run(FOLD, KEEP);--require('../server/identity/blocks').ensureSchema(db);--db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(FOLD, OTHER, FOLD, KEEP);++++ // paste 1: both liked(async () => {++ SECRET = `whsec_${'ef'.repeat(32)}`;++ db = await testDb();++ app = express();++ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], log: { error() {}, log() {}, warn() {} } }).router);++ KEEP = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';++ FOLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';++ OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';++ MERGE = 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4';++ evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN09', event_type: 'network.subject.merged', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),++ subject: { type: 'user', id: KEEP }, actor: { type: 'user', id: KEEP },++ payload: { merge_id: MERGE, from: FOLD, into: KEEP, merged_at: new Date().toISOString(), initiated_by: 'person' }, ...over });++ await db.prepare("INSERT INTO pastes (id, slug, owner_subject, likes) OVERRIDING SYSTEM VALUE VALUES (1, 'p1', ?, 2), (2, 'p2', ?, 1)").run(FOLD, OTHER);++ await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (1, ?), (1, ?), (2, ?)').run(FOLD, KEEP, FOLD);++ await db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'vod', '9')").run();++ await db.prepare("INSERT INTO comments (id, thread_id, message, author_subject, score, upvotes, downvotes) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'hi', ?, 2, 2, 0)").run(OTHER);++ await db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (1, ?, 1), (1, ?, 1)').run(FOLD, KEEP);++ await db.prepare("INSERT INTO spaces (id, slug, name) OVERRIDING SYSTEM VALUE VALUES (901, 'merge-test', 'Merge test')").run();++ await db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) OVERRIDING SYSTEM VALUE VALUES (1, 901, 't1', 'T1', ?, 1)").run(FOLD);++ await db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (1, ?, 1)').run(FOLD);++ await db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'hello', ?)").run(FOLD);++ await db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (1, ?, 'fire'), (1, ?, 'heart')").run(FOLD, KEEP);++ await db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 5, '2026-09-27')").run(FOLD, KEEP);++ await db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(FOLD, OTHER, FOLD, KEEP);assert.ok(TOPICS.includes('network.subject.merged'), 'Community subscribes to merges');const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });const post = (event) => {@@@ -52,20 +56,20 @@ db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, activer = await post(evt());assert.deepStrictEqual([r.status, r.json.outcome], [200, 'merge:applied']);-- const owner = (sql, ...a) => db.prepare(sql).get(...a);-- assert.strictEqual(owner('SELECT owner_subject AS s FROM pastes WHERE id = 1').s, KEEP);-- assert.strictEqual(owner('SELECT author_subject AS a FROM threads WHERE id = 1').a, KEEP);-- assert.strictEqual(owner('SELECT author_subject AS a FROM posts WHERE id = 1').a, KEEP);++ const owner = async (sql, ...a) => await db.prepare(sql).get(...a);++ assert.strictEqual((await owner('SELECT owner_subject AS s FROM pastes WHERE id = 1')).s, KEEP);++ assert.strictEqual((await owner('SELECT author_subject AS a FROM threads WHERE id = 1')).a, KEEP);++ assert.strictEqual((await owner('SELECT author_subject AS a FROM posts WHERE id = 1')).a, KEEP);// Likes: paste 1 had both (one dropped, count 1); paste 2's like moved.-- assert.deepStrictEqual(db.prepare('SELECT paste_id AS p, subject_id AS s FROM paste_likes ORDER BY p').all().map((x) => [x.p, x.s]), [[1, KEEP], [2, KEEP]]);-- assert.strictEqual(owner('SELECT likes FROM pastes WHERE id = 1').likes, 1, 'the cached count follows the rows');++ assert.deepStrictEqual((await db.prepare('SELECT paste_id AS p, subject_id AS s FROM paste_likes ORDER BY p').all()).map((x) => [x.p, x.s]), [[1, KEEP], [2, KEEP]]);++ assert.strictEqual((await owner('SELECT likes FROM pastes WHERE id = 1')).likes, 1, 'the cached count follows the rows');// Votes: comment 1 had both (score recomputed to 1); thread 1's vote moved.-- assert.deepStrictEqual(owner('SELECT score, upvotes, downvotes FROM comments WHERE id = 1'), { score: 1, upvotes: 1, downvotes: 0 });-- assert.deepStrictEqual(db.prepare('SELECT subject_id AS s FROM thread_votes').all().map((x) => x.s), [KEEP]);-- assert.deepStrictEqual(db.prepare('SELECT subject_id AS s, reaction FROM post_reactions').all(), [{ s: KEEP, reaction: 'heart' }], 'the survivor\'s reaction stays');-- assert.deepStrictEqual(db.prepare('SELECT subject_id AS s, level FROM game_progress').all(), [{ s: KEEP, level: 5 }]);-- assert.deepStrictEqual(db.prepare('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').all(), [{ a: KEEP, b: OTHER }], 'moved; blocking oneself dropped');-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM pastes WHERE owner_subject = ?').get(FOLD).n + db.prepare('SELECT COUNT(*) AS n FROM paste_likes WHERE subject_id = ?').get(FOLD).n, 0);++ assert.deepStrictEqual(await owner('SELECT score, upvotes, downvotes FROM comments WHERE id = 1'), { score: 1, upvotes: 1, downvotes: 0 });++ assert.deepStrictEqual((await db.prepare('SELECT subject_id AS s FROM thread_votes').all()).map((x) => x.s), [KEEP]);++ assert.deepStrictEqual(await db.prepare('SELECT subject_id AS s, reaction FROM post_reactions').all(), [{ s: KEEP, reaction: 'heart' }], 'the survivor\'s reaction stays');++ assert.deepStrictEqual(await db.prepare('SELECT subject_id AS s, level FROM game_progress').all(), [{ s: KEEP, level: 5 }]);++ assert.deepStrictEqual(await db.prepare('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').all(), [{ a: KEEP, b: OTHER }], 'moved; blocking oneself dropped');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM pastes WHERE owner_subject = ?').get(FOLD)).n + (await db.prepare('SELECT COUNT(*) AS n FROM paste_likes WHERE subject_id = ?').get(FOLD)).n, 0);r = await post(evt());assert.deepStrictEqual([r.status, r.json.duplicate], [200, true], 'a redelivery changes nothing');@@@ -1,110 +1,81 @@'use strict';/**-- * Vote races. A vote is one UPSERT plus a score recomputed from the rows in the same IMMEDIATE-- * transaction (server/votes.js), so however writes interleave the stored score is the sum of the-- * vote rows. Checked two ways against a real database file:-- * 1. two connections in one process, their writes interleaved step by step;-- * 2. four worker threads hammering the same comment and thread at once.++ * Vote races. A vote is one UPSERT plus a score recomputed from the rows in the same transaction, after locking++ * the target row (server/votes.js), so however writes interleave the stored score is the sum of the vote rows.++ * Checked on a real database: interleaved writes step by step, a stale reader, then 1000 votes with 32 in flight at a++ * time (under npm run test:pg they run on separate PostgreSQL connections through PgBouncer).*/--const { Worker, isMainThread, workerData, parentPort } = require('worker_threads');--const path = require('path');--const fs = require('fs');--const os = require('os');----if (!isMainThread) {-- // ── worker: random votes by a small crowd, as fast as it can ──-- const { openDb } = require('../server/db');-- const { applyVote } = require('../server/votes');-- const db = openDb(workerData.file);-- let seed = workerData.seed;-- const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };-- let done = 0;-- try {-- for (let i = 0; i < workerData.ops; i++) {-- const subject = workerData.subjects[rand(workerData.subjects.length)];-- const value = [1, -1, 0][rand(3)];-- if (rand(2)) applyVote(db, 'comment', workerData.commentId, subject, value);-- else applyVote(db, 'thread', workerData.threadId, subject, value);-- done++;-- }-- parentPort.postMessage({ ok: true, done });-- } catch (err) {-- parentPort.postMessage({ ok: false, done, error: err.message });-- } finally { db.close(); }-- return;--}--const assert = require('assert');const { ids } = require('openvibe-contracts');--const { openDb } = require('../server/db');++const { testDb } = require('./helpers/db');const { applyVote } = require('../server/votes');const commentStore = require('../server/comments/store');const forumStore = require('../server/forum/store');const { check, done } = require('./helpers/app');--const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-votes-'));--const file = path.join(dir, 'votes.db');--const a = openDb(file);--const b = openDb(file);----const { thread: cthread } = commentStore.resolveThread(a, { service: 'live', type: 'vod', id: 'race' });--const comment = commentStore.insertComment(a, { thread_id: cthread.id, message: 'vote on me' });--const space = forumStore.getSpace(a, 'general');--const { thread } = forumStore.createThread(a, { space_id: space.id, title: 'Race thread', body_markdown: 'go' });--const subjects = Array.from({ length: 10 }, () => ids.newId('user'));++let db, comment, thread, subjects;--function invariant(db, label) {-- const c = db.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);-- const cv = db.prepare(`SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(value = 1), 0) AS up, COALESCE(SUM(value = -1), 0) AS down FROM comment_votes WHERE comment_id = ?`).get(comment.id);++async function invariant(label) {++ const c = await db.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);++ const cv = await db.prepare(`SELECT COALESCE(SUM(value), 0)::bigint AS s, COUNT(*) FILTER (WHERE value = 1) AS up, COUNT(*) FILTER (WHERE value = -1) AS down++ FROM comment_votes WHERE comment_id = ?`).get(comment.id);assert.deepStrictEqual([c.score, c.upvotes, c.downvotes], [cv.s, cv.up, cv.down], `${label}: comment score is the sum of its votes`);-- const t = db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);-- const tv = db.prepare('SELECT COALESCE(SUM(value), 0) AS s FROM thread_votes WHERE thread_id = ?').get(thread.id);++ const t = await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);++ const tv = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s FROM thread_votes WHERE thread_id = ?').get(thread.id);assert.strictEqual(t.score, tv.s, `${label}: thread score is the sum of its votes`);-- const dup = db.prepare('SELECT COUNT(*) AS n FROM (SELECT subject_id FROM comment_votes WHERE comment_id = ? GROUP BY subject_id HAVING COUNT(*) > 1)').get(comment.id).n;++ const dup = (await db.prepare('SELECT COUNT(*) AS n FROM (SELECT subject_id FROM comment_votes WHERE comment_id = ? GROUP BY subject_id HAVING COUNT(*) > 1) d').get(comment.id)).n;assert.strictEqual(dup, 0, `${label}: one vote per person`);}(async () => {-- await check('interleaved UPSERTs on two connections: add, flip, remove — the score never drifts', () => {++ db = await testDb({ max: 8 });++ const { thread: cthread } = await commentStore.resolveThread(db, { service: 'live', type: 'vod', id: 'race' });++ comment = await commentStore.insertComment(db, { thread_id: cthread.id, message: 'vote on me' });++ const space = await forumStore.getSpace(db, 'general');++ ({ thread } = await forumStore.createThread(db, { space_id: space.id, title: 'Race thread', body_markdown: 'go' }));++ subjects = Array.from({ length: 10 }, () => ids.newId('user'));++++ await check('interleaved votes: add, flip, remove — the score never drifts', async () => {const [s1, s2, s3] = subjects;-- const steps = [-- [a, s1, 1], [b, s1, -1], [a, s2, 1], [b, s2, 1], [a, s1, -1], [b, s3, -1],-- [a, s3, 0], [b, s1, 1], [a, s2, 0], [b, s2, -1], [a, s3, 1], [b, s3, 1],-- ];-- for (const [db, s, v] of steps) {-- const out = applyVote(db, 'comment', comment.id, s, v);-- applyVote(db === a ? b : a, 'thread', thread.id, s, v);-- invariant(a, 'after step'); invariant(b, 'other connection');++ const steps = [[s1, 1], [s1, -1], [s2, 1], [s2, 1], [s1, -1], [s3, -1], [s3, 0], [s1, 1], [s2, 0], [s2, -1], [s3, 1], [s3, 1]];++ for (const [s, v] of steps) {++ const out = await applyVote(db, 'comment', comment.id, s, v);++ await applyVote(db, 'thread', thread.id, s, v);++ await invariant('after step');assert.strictEqual(out.my_vote, v);}// Final state is the last write per person: s1 +1, s2 −1, s3 +1.-- const c = a.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);-- assert.deepStrictEqual(c, { score: 1, upvotes: 2, downvotes: 1 });++ const c = await db.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);++ assert.deepStrictEqual({ ...c }, { score: 1, upvotes: 2, downvotes: 1 });});-- await check('a stale reader cannot overwrite a newer score: the recount reads the rows inside the write lock', () => {-- // Connection b reads, a writes, then b writes: b's score comes from the rows, not from what it read.-- const staleScore = b.prepare('SELECT score FROM comments WHERE id = ?').get(comment.id).score;-- applyVote(a, 'comment', comment.id, subjects[4], 1);-- const out = applyVote(b, 'comment', comment.id, subjects[5], 1);++ await check('a stale reader cannot overwrite a newer score: the recount reads the rows under the row lock', async () => {++ const staleScore = (await db.prepare('SELECT score FROM comments WHERE id = ?').get(comment.id)).score;++ await applyVote(db, 'comment', comment.id, subjects[4], 1);++ const out = await applyVote(db, 'comment', comment.id, subjects[5], 1);assert.strictEqual(out.score, staleScore + 2);-- invariant(a, 'stale reader');++ await invariant('stale reader');});-- await check('four worker threads voting concurrently on one comment and one thread: invariants hold, no busy errors', async () => {-- const results = await Promise.all([1, 2, 3, 4].map((n) => new Promise((resolve, reject) => {-- const w = new Worker(__filename, { workerData: { file, seed: n * 7919, ops: 250, subjects, commentId: comment.id, threadId: thread.id } });-- w.once('message', resolve);-- w.once('error', reject);-- })));-- for (const r of results) assert.ok(r.ok, `worker failed: ${r.error}`);-- assert.strictEqual(results.reduce((s, r) => s + r.done, 0), 1000);-- invariant(a, 'after the storm');-- const rows = a.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(comment.id).n;++ await check('1000 votes, 32 in flight at a time, on one comment and one thread: invariants hold, none fails', async () => {++ let seed = 7919;++ const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };++ const ops = Array.from({ length: 1000 }, () => [subjects[rand(subjects.length)], [1, -1, 0][rand(3)], rand(2)]);++ // 32 in flight at a time: more than the pool's connections, so votes queue and interleave on the row locks.++ const failed = [];++ let next = 0;++ await Promise.all(Array.from({ length: 32 }, async () => {++ while (next < ops.length) {++ const [s, v, onComment] = ops[next++];++ try { await (onComment ? await applyVote(db, 'comment', comment.id, s, v) : await applyVote(db, 'thread', thread.id, s, v)); } catch (err) { failed.push(err); }++ }++ }));++ assert.strictEqual(failed.length, 0, failed.length ? failed[0].message : '');++ await invariant('after the storm');++ const rows = (await db.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(comment.id)).n;assert.ok(rows <= subjects.length);});-- a.close(); b.close();-- fs.rmSync(dir, { recursive: true, force: true });++ await db.close();done();})();
@@ -57,8 +57,15 @@ OV_CHAT_URL=https://openvibe.chat
@@ -57,8 +57,15 @@ OV_CHAT_URL=https://openvibe.chat
# community this site's own database is the authority (native API, pages, raw text)
# community this site's own database is the authority (native API, pages, raw text)
# With 'community' the OAuth client above also needs identity.subject.resolve (author names).
# With 'community' the OAuth client above also needs identity.subject.resolve (author names).
PASTES_AUTHORITY=live
PASTES_AUTHORITY=live
-# SQLite database (production unit: /var/lib/openvibe-community/community.db). Comments, the
-# forum, Pulse and the relay's bookkeeping live here in every mode; pastes too with 'community'.
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh community: the runtime role through
+# PgBouncer, the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit
+# counters). Comments, the forum, Pulse and the relay's bookkeeping live there in every mode; pastes too with
+# 'community'. 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.
COMMUNITY_DB_PATH=./data/community.db
COMMUNITY_DB_PATH=./data/community.db
# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
#VIEW_HASH_SECRET=
#VIEW_HASH_SECRET=
@@ -6,11 +6,13 @@ on:
@@ -6,11 +6,13 @@ on:
jobs:
jobs:
# install, node --check, npm test (with test/n-1.test.js: the previous release's pages, forms and SDK
# install, node --check, npm test (with test/n-1.test.js: the previous release's pages, forms and SDK
# calls, and its SQL, against this one, from test/fixtures/n-1) and openvibe-contracts-check: the shared
# calls, and its SQL, against this one, from test/fixtures/n-1) and openvibe-contracts-check: the shared
- # test job (Track Q).
+ # test job (Track Q). 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:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: community
contracts-service: community
+ 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
@@ -9,7 +9,7 @@ the rules. It is the home of **pastes** (code, text and screenshots with a link)
@@ -9,7 +9,7 @@ the rules. It is the home of **pastes** (code, text and screenshots with a link)
**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe product
**forum** (spaces, threads and posts), the **comment threads** every other OpenVibe product
embeds, and **Pulse**, the network's public activity. Submissions follow.
embeds, and **Pulse**, the network's public activity. Submissions follow.
-It is a small Node/Express app (CommonJS, no framework, one SQLite database) that
+It is a small Node/Express app (CommonJS, no framework, one PostgreSQL database) that
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
little progressive JavaScript for pagination, copy buttons and the upload path.
little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -20,7 +20,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -20,7 +20,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,
- the typed comment threads every other product embeds, the forum (spaces, threads, posts, votes,
categories), Pulse (the network's public activity) and the Discord relay
categories), Pulse (the network's public activity) and the Discord relay
- the `community.*` events, Search documents for public threads, and the `community.profile` user module
- the `community.*` events, Search documents for public threads, and the `community.profile` user module
-- one SQLite database (`/var/lib/openvibe-community/community.db` in production)
+- one PostgreSQL database (`ov_community` on the host's data role, ADR-035; schema in [migrations/](migrations/);
+ embedded PGlite in development), with Valkey for the per-actor limit counters
## Does not own
## Does not own
@@ -34,8 +35,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -34,8 +35,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only when `PASTES_AUTHORITY=live` (the rollback mode), and for old VOD/clip comment imports
- OpenVibe.Live only when `PASTES_AUTHORITY=live` (the rollback mode), and for old VOD/clip comment imports
-- `openvibe-contracts` v0.71.0, `openvibe-sdk` v0.12.0 (events outbox, per-actor limits),
- `openvibe-shared` v1.25.0, pinned by release tarball
+- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.0 (events outbox, per-actor limits),
+ `openvibe-shared` v1.28.0, pinned by release tarball
## How it fits the network
## How it fits the network
@@ -105,14 +106,9 @@ Moments feeds read it.
@@ -105,14 +106,9 @@ Moments feeds read it.
thing keeping it unlisted. Existing slugs never change and keep working.
thing keeping it unlisted. Existing slugs never change and keep working.
- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.
- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.
-Moving the data: `node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff
-2026-08-20T03:00:26Z]` imports Media's `openvibe.media.pastes-export` bundle. It checks the
-bundle's format, counts and sha256 first, is idempotent (upsert by `legacy_media_id`; a newer
-bundle adds and updates, counters never go down, local edits and deletes stay), maps Live user
-ids to subjects through the Network (rows before the cutoff are also checked as Network ids;
-disagreement or no answer imports the row ownerless and records an `import_hold`), keeps AI
-moment pastes ownerless with origin `ai`, and prints a reconciliation report (also stored in
-`migration_runs`). `--dry-run` rolls everything back.
+Moving the data: Media's `openvibe.media.pastes-export` bundle was imported once (Wave 5, 2026-09-22; the ledger is
+`legacy_id_map`, the report `migration_runs`). The importer (`scripts/import-pastes.js`) was retired with the move to
+PostgreSQL and is in git history.
`/by-user/:username` and `?username=` look the name up in `subject_projection` (the Network
`/by-user/:username` and `?username=` look the name up in `subject_projection` (the Network
has no service-token lookup by username), so a person appears there once Community has seen
has no service-token lookup by username), so a person appears there once Community has seen
@@ -161,7 +157,7 @@ API and machine endpoints:
@@ -161,7 +157,7 @@ API and machine endpoints:
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
-when the required `db` check fails (a real query on Community's SQLite). `network_jwks` (the
+when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the
Network signing key; without it nobody can sign in or write as a signed-in viewer or service),
Network signing key; without it nobody can sign in or write as a signed-in viewer or service),
`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`
`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`
(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready
(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready
@@ -250,39 +246,10 @@ item's comments.
@@ -250,39 +246,10 @@ item's comments.
3. Service-side, the same answer: `GET /api/v1/comments/threads/<id>?sort=new` with Live's token
3. Service-side, the same answer: `GET /api/v1/comments/threads/<id>?sort=new` with Live's token
returns the comments both pages render (`test/comments.test.js` checks the page against it).
returns the comments both pages render (`test/comments.test.js` checks the page against it).
-**Moving Live's old rows** (`scripts/import-live-comments.js`, `server/comments/live-import.js`):
-reads Live's `comments` table read-only, puts every row in exactly one bucket and prints the
-reconciliation `read = imported + held + excluded`. Imported rows keep their text and times
-(edits keep `edited_at`); the ledger is `legacy_id_map` (`live`/`comment`/<live id> → comment
-id), so re-running imports nothing twice. Authors become Network subjects from Live's
-`linked_accounts.subject_id` and the Network's identity map (system `live`); a row whose author
-maps nowhere (`unmapped_author`), maps two ways (`ambiguous_author`) or whose parent is held
-(`parent_held`) is **held** in `import_hold` (source type `live_comment`) and listed, and a later
-run imports it once the mapping exists. Deleted rows and replies under them, orphans and empty
-messages are **excluded**. Dry run is the default (everything rolled back); `--apply` requires
-`--backup <new file>`, an online backup of Community's database that is integrity-checked
-before anything is written. Community must be deployed first (the script refuses a database
-without `comments.edited_at` rather than migrate it from a dry run).
-
-Production (on the host, as the service's own user and environment through `systemd-run`, so
-no file in `/var/lib/openvibe-community` ends up owned by root). Order: deploy Community → dry
-run → apply → deploy Live → apply again (catches comments Live wrote in between; the ledger
-skips the rest) → verify as above.
-
-```
-RUN="sudo systemd-run --wait --pipe --collect -p User=ubuntu -p Group=ubuntu \
- -p WorkingDirectory=/opt/openvibe.community -p EnvironmentFile=/etc/openvibe/community.env \
- -E NODE_ENV=production -E COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db"
-ARGS="--live-db /opt/openvibe.live/data/live.db --community-db /var/lib/openvibe-community/community.db"
-
-$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS # dry run
-$RUN /usr/bin/env node scripts/import-live-comments.js $ARGS --apply \
- --backup /var/lib/openvibe-community/community.pre-live-comments-$(date -u +%Y%m%dT%H%M%SZ).db
-```
-
-Held rows: `sqlite3 /var/lib/openvibe-community/community.db "SELECT * FROM import_hold WHERE
-source_type = 'live_comment'"`. Rollback: stop Community, copy the backup over `community.db`
-(remove `community.db-wal`/`-shm`), start it; Live's own rows were never changed.
+**Moving Live's old rows**: Live's VOD and clip comments were imported once (ledger `legacy_id_map`, `live`/`comment`;
+held rows in `import_hold`, source type `live_comment`). The importer (`scripts/import-live-comments.js`,
+`server/comments/live-import.js`) was retired with the move to PostgreSQL and is in git history, as is C-24's
+`scripts/migrate-screenshot-refs.js` (applied 2026-09-25: no paste keeps a legacy media reference).
Embedding it — server-side, from another product's backend (the usual way; the person is the
Embedding it — server-side, from another product's backend (the usual way; the person is the
one your own session says it is):
one your own session says it is):
@@ -546,7 +513,7 @@ Community checks service tokens against these capabilities (manifests in
@@ -546,7 +513,7 @@ Community checks service tokens against these capabilities (manifests in
| `community.post.create` | active in contracts (v0.7.0) | forum writes |
| `community.post.create` | active in contracts (v0.7.0) | forum writes |
| `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |
| `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |
-This repository pins `openvibe-contracts` v0.71.0, which knows every id above, so they all go
+This repository pins `openvibe-contracts` v0.76.0, which knows every id above, so they all go
through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
the installed contracts do not know locally, with the library's own matching rule (the exact id
the installed contracts do not know locally, with the library's own matching rule (the exact id
or a `prefix.*` grant).
or a `prefix.*` grant).
@@ -618,7 +585,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -618,7 +585,9 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |
| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |
| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |
| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |
-| `COMMUNITY_DB_PATH` | `./data/community.db` | SQLite file (the systemd unit sets `/var/lib/openvibe-community/community.db`) |
+| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset (development: embedded PGlite in `data/pglite`) | PostgreSQL through PgBouncer, and the owner's direct connection for migrations (written by OpenVibe.Host `roles/data/add-service.sh community`) |
+| `VALKEY_URL`, `VALKEY_PREFIX` | unset | per-actor limit counters shared across processes |
+| `COMMUNITY_DB_PATH` | `./data/community.db` | the SQLite file of releases before PostgreSQL, read once by `scripts/migrate-to-postgres.js` |
| `API_CORS_ORIGINS` | Live, Media, Network, Tools, Games origins | Browser origins that may call `/api/v1/comments` and `/api/v1/pulse` with a Bearer JWT |
| `API_CORS_ORIGINS` | Live, Media, Network, Tools, Games origins | Browser origins that may call `/api/v1/comments` and `/api/v1/pulse` with a Bearer JWT |
| `DISCORD_RELAY_ENABLED` | off | `true` turns the Discord relay on ([docs/discord-relay.md](docs/discord-relay.md)) |
| `DISCORD_RELAY_ENABLED` | off | `true` turns the Discord relay on ([docs/discord-relay.md](docs/discord-relay.md)) |
| `DISCORD_RELAY_POLL_MS` / `DISCORD_RELAY_BACKOFF_MS` / `DISCORD_RELAY_MAX_ATTEMPTS` | `30000` / `30000` / `6` | Relay sender cadence, first retry delay, attempts before `failed` (the dead letter) |
| `DISCORD_RELAY_POLL_MS` / `DISCORD_RELAY_BACKOFF_MS` / `DISCORD_RELAY_MAX_ATTEMPTS` | `30000` / `30000` / `6` | Relay sender cadence, first retry delay, attempts before `failed` (the dead letter) |
@@ -694,11 +663,14 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, T
@@ -694,11 +663,14 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, T
Production deploys with `sudo ovhost deploy community` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy community` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.community`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.community`, install on a lockfile change, restart, wait for `/api/ready`).
-The unit is `openvibe-community.service` on `127.0.0.1:4200`, the env file `/etc/openvibe/community.env`. After a
+The unit is `openvibe-community.service` on `127.0.0.1:4200`, the env file `/etc/openvibe/community.env`. The database is
+`ov_community` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh community` 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-community/community.db` stays read-only for 7 days as the rollback. After a
deploy, record the N-1 fixtures (`npm run n-1:record`).
deploy, record the N-1 fixtures (`npm run n-1:record`).
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 community --to <sha>`. Nothing blocks a rollback: the schema
-code only adds tables and columns.
+restart; afterwards `sudo ovhost rollback community --to <sha>`. Migrations only add tables and columns.
The schema
The schema
is created idempotently at boot in every mode (comments, the forum, Pulse and the relay live
is created idempotently at boot in every mode (comments, the forum, Pulse and the relay live
@@ -724,7 +696,7 @@ server/
@@ -724,7 +696,7 @@ server/
graceful.js SIGTERM: stop timers and scans, drain HTTP (4 s), settle the relays, close the DB, exit 0 (5 s at most)
graceful.js SIGTERM: stop timers and scans, drain HTTP (4 s), settle the relays, close the DB, exit 0 (5 s at most)
app.js Express app factory: middleware, routes
app.js Express app factory: middleware, routes
config.js env → config
config.js env → config
- db.js SQLite (better-sqlite3): schema, opened at COMMUNITY_DB_PATH
+ db.js PostgreSQL (openvibe-sdk/db): initDb/getDb, migrations/ applied at boot
auth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuth
auth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuth
identity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymous
identity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymous
identity/network.js Network resolve-batch client + subject_projection cache
identity/network.js Network resolve-batch client + subject_projection cache
@@ -735,7 +707,6 @@ server/
@@ -735,7 +707,6 @@ server/
pastes/api.js native /api/pastes/* ('community')
pastes/api.js native /api/pastes/* ('community')
pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
pastes/store.js pure SQL over pastes / versions / likes / comments / projections
pastes/store.js pure SQL over pastes / versions / likes / comments / projections
- pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)
pastes/source.js where pages read pastes from (Live or the store)
pastes/source.js where pages read pastes from (Live or the store)
pastes/catalog.js recent public pastes: trending, related, language filter
pastes/catalog.js recent public pastes: trending, related, language filter
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
@@ -761,8 +732,7 @@ server/
@@ -761,8 +732,7 @@ server/
public/ css/community.css, js/community.js, favicon.svg, og-default.png
public/ css/community.css, js/community.js, favicon.svg, og-default.png
(openvibe-shared is the pinned OpenVibe.Shared v1.25.0 release, installed by npm)
(openvibe-shared is the pinned OpenVibe.Shared v1.25.0 release, installed by npm)
deploy/ systemd unit, nginx vhost
deploy/ systemd unit, nginx vhost
-scripts/import-pastes.js Media paste bundle importer
-scripts/import-live-comments.js Live's VOD/clip comments → Community threads (dry run by default)
+scripts/migrate-to-postgres.js the one-time SQLite → PostgreSQL import (runSqliteMigration; --pglite rehearsal)
test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)
docs/capabilities-proposal/ Wave 5 capability manifests (released in openvibe-contracts v0.7.0)
docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits
docs/discord-relay.md the Discord relay: how it works, owner steps, staff API, limits
@@ -4,10 +4,10 @@
@@ -4,10 +4,10 @@
"code": true,
"code": true,
"wave": "W5",
"wave": "W5",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express, better-sqlite3",
+ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4200,
"port": 4200,
"domain": "openvibe.community",
"domain": "openvibe.community",
- "contracts": "openvibe-contracts v0.71.0",
+ "contracts": "openvibe-contracts v0.76.0",
"capabilities": [
"capabilities": [
"community.paste.create",
"community.paste.create",
"community.paste.write",
"community.paste.write",
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
Description=OpenVibe.Community — The people of OpenVibe (pastes; forums and submissions to follow)
Description=OpenVibe.Community — The people of OpenVibe (pastes; forums and submissions to follow)
# Live (openvibe-live) and Network (openvibe-network) should be up first, but
# Live (openvibe-live) and Network (openvibe-network) should be up first, but
# Community degrades gracefully if they aren't (paste pages answer 502, sign-in retries).
# Community degrades gracefully if they aren't (paste pages answer 502, sign-in retries).
-After=network.target
-Wants=openvibe-live.service openvibe-network.service
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-live.service openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
@@ -21,8 +21,8 @@ NoNewPrivileges=true
@@ -21,8 +21,8 @@ NoNewPrivileges=true
PrivateTmp=true
PrivateTmp=true
ProtectSystem=strict
ProtectSystem=strict
ProtectHome=true
ProtectHome=true
-# Community's own database (used once PASTES_AUTHORITY=community; untouched before that).
-# The code tree stays read-only; the database lives in /var/lib/openvibe-community.
+# The code tree stays read-only. Community's database is PostgreSQL (ov_community, ADR-035); /var/lib/openvibe-community
+# keeps the pre-PostgreSQL SQLite file (read-only) for the rollback window and the one-time import.
StateDirectory=openvibe-community
StateDirectory=openvibe-community
Environment=COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db
Environment=COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db
new file mode 100644
new file mode 100644
@@ -0,0 +1,514 @@
@@ -0,0 +1,514 @@
+-- phase: expand
+-- OpenVibe.Community 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.
+-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).
+CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$
+BEGIN
+ IF t IS NULL THEN RETURN NULL; END IF;
+ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;
+ IF t ~ '\d\d:\d\d(:\d\d(\.\d+)?)?\s*(Z|[+-]\d\d(:?\d\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;
+ RETURN t::timestamp;
+EXCEPTION WHEN others THEN RETURN NULL;
+END $$;
+CREATE FUNCTION ov_now() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') $$;
+CREATE FUNCTION ov_now_iso() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
+CREATE FUNCTION datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$
+DECLARE ts timestamp := ov_ts(t);
+BEGIN
+ IF ts IS NULL THEN RETURN NULL; END IF;
+ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;
+ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');
+EXCEPTION WHEN others THEN RETURN NULL;
+END $$;
+CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;
+-- The forum's hot rank (server/forum/store.js): (score + 1) / (age in hours + 2)^1.5, the Hacker News gravity.
+CREATE FUNCTION ov_hot(score double precision, age_hours double precision) RETURNS double precision LANGUAGE sql IMMUTABLE AS $$ SELECT (COALESCE(score, 0) + 1) / power(GREATEST(COALESCE(age_hours, 0), 0) + 2, 1.5) $$;
+
+CREATE TABLE pastes (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ slug text COLLATE "C" UNIQUE NOT NULL,
+ owner_subject text COLLATE "C",
+ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),
+ type text COLLATE "C" NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),
+ title text COLLATE "C" NOT NULL DEFAULT 'Untitled',
+ content text COLLATE "C",
+ language text COLLATE "C" DEFAULT 'text',
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),
+ screenshot_url text COLLATE "C",
+ media_ref text COLLATE "C",
+ stream_ref text COLLATE "C",
+ metadata text COLLATE "C",
+ burn_after_read bigint NOT NULL DEFAULT 0,
+ forked_from bigint REFERENCES pastes(id) ON DELETE SET NULL,
+ pinned bigint NOT NULL DEFAULT 0,
+ views bigint NOT NULL DEFAULT 0,
+ unique_views bigint NOT NULL DEFAULT 0,
+ copies bigint NOT NULL DEFAULT 0,
+ likes bigint NOT NULL DEFAULT 0,
+ is_nsfw bigint NOT NULL DEFAULT 0,
+ ai_summary text COLLATE "C",
+ ai_tags text COLLATE "C",
+ ai_analyzed_at text COLLATE "C",
+ revision bigint NOT NULL DEFAULT 1,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ deleted_at text COLLATE "C",
+ legacy_media_id bigint UNIQUE,
+ legacy_user_id bigint
+);
+
+CREATE TABLE paste_versions (
+ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ revision bigint NOT NULL,
+ title text COLLATE "C",
+ content text COLLATE "C",
+ language text COLLATE "C",
+ edited_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (paste_id, revision)
+);
+
+CREATE TABLE paste_likes (
+ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ subject_id text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (paste_id, subject_id)
+);
+
+CREATE TABLE paste_comments (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ author_subject text COLLATE "C",
+ anon_name text COLLATE "C",
+ parent_id bigint REFERENCES paste_comments(id) ON DELETE CASCADE,
+ message text COLLATE "C" NOT NULL,
+ is_deleted bigint NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ legacy_media_id bigint UNIQUE
+);
+
+CREATE TABLE paste_visits (
+ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ visitor text COLLATE "C" NOT NULL,
+ first_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ last_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ visits bigint NOT NULL DEFAULT 1,
+ PRIMARY KEY (paste_id, visitor)
+);
+
+CREATE TABLE subject_projection (
+ subject_id text COLLATE "C" PRIMARY KEY,
+ username text COLLATE "C",
+ display_name text COLLATE "C",
+ avatar_url text COLLATE "C",
+ profile_color text COLLATE "C",
+ refreshed_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE TABLE migration_runs (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ source text COLLATE "C" NOT NULL,
+ started_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ finished_at text COLLATE "C",
+ report text COLLATE "C"
+);
+
+CREATE TABLE legacy_id_map (
+ source_system text COLLATE "C" NOT NULL,
+ source_type text COLLATE "C" NOT NULL,
+ source_id text COLLATE "C" NOT NULL,
+ target_type text COLLATE "C" NOT NULL,
+ target_id text COLLATE "C" NOT NULL,
+ PRIMARY KEY (source_system, source_type, source_id)
+);
+
+CREATE TABLE comment_threads (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ ref_service text COLLATE "C" NOT NULL,
+ ref_type text COLLATE "C" NOT NULL,
+ ref_id text COLLATE "C" NOT NULL,
+ ref_label text COLLATE "C",
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')),
+ comment_count bigint NOT NULL DEFAULT 0,
+ created_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ access_id text COLLATE "C",
+ UNIQUE (ref_service, ref_type, ref_id)
+);
+
+CREATE TABLE comments (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ thread_id bigint NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
+ parent_id bigint REFERENCES comments(id) ON DELETE CASCADE,
+ author_subject text COLLATE "C",
+ anon_name text COLLATE "C",
+ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai')),
+ message text COLLATE "C" NOT NULL,
+ score bigint NOT NULL DEFAULT 0,
+ upvotes bigint NOT NULL DEFAULT 0,
+ downvotes bigint NOT NULL DEFAULT 0,
+ reply_count bigint NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ deleted_at text COLLATE "C",
+ deleted_by text COLLATE "C",
+ edited_at text COLLATE "C"
+);
+
+CREATE TABLE comment_votes (
+ comment_id bigint NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
+ subject_id text COLLATE "C" NOT NULL,
+ value bigint NOT NULL CHECK(value IN (-1, 1)),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (comment_id, subject_id)
+);
+
+CREATE TABLE space_groups (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ slug text COLLATE "C" UNIQUE NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ position bigint NOT NULL DEFAULT 0
+);
+
+CREATE TABLE spaces (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ slug text COLLATE "C" UNIQUE NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),
+ created_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ members_only_owner text COLLATE "C",
+ thread_kind text COLLATE "C" NOT NULL DEFAULT 'discussion',
+ style text COLLATE "C" NOT NULL DEFAULT 'feed',
+ votes bigint NOT NULL DEFAULT 1,
+ reactions bigint NOT NULL DEFAULT 1,
+ group_id bigint REFERENCES space_groups(id) ON DELETE SET NULL,
+ parent_id bigint REFERENCES spaces(id) ON DELETE SET NULL,
+ position bigint NOT NULL DEFAULT 0
+);
+
+CREATE TABLE categories (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ slug text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ position bigint NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ UNIQUE (space_id, slug)
+);
+
+CREATE TABLE threads (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ slug text COLLATE "C" NOT NULL,
+ title text COLLATE "C" NOT NULL,
+ author_subject text COLLATE "C",
+ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
+ pinned bigint NOT NULL DEFAULT 0,
+ locked bigint NOT NULL DEFAULT 0,
+ score bigint NOT NULL DEFAULT 0,
+ reply_count bigint NOT NULL DEFAULT 0,
+ last_activity_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ deleted_at text COLLATE "C",
+ members_only_owner text COLLATE "C",
+ kind text COLLATE "C" NOT NULL DEFAULT 'discussion',
+ status text COLLATE "C",
+ category_id bigint REFERENCES categories(id) ON DELETE SET NULL,
+ external_key text COLLATE "C",
+ views bigint NOT NULL DEFAULT 0,
+ crosspost_of bigint REFERENCES threads(id) ON DELETE SET NULL,
+ UNIQUE (space_id, slug)
+);
+
+CREATE TABLE posts (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
+ author_subject text COLLATE "C",
+ origin text COLLATE "C" NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
+ is_opening bigint NOT NULL DEFAULT 0,
+ body_markdown text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL DEFAULT 1,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ deleted_at text COLLATE "C",
+ relay_author text COLLATE "C"
+);
+
+CREATE TABLE post_versions (
+ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+ revision bigint NOT NULL,
+ body_markdown text COLLATE "C" NOT NULL,
+ edited_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (post_id, revision)
+);
+
+CREATE TABLE thread_votes (
+ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
+ subject_id text COLLATE "C" NOT NULL,
+ value bigint NOT NULL CHECK(value IN (-1, 1)),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (thread_id, subject_id)
+);
+
+CREATE TABLE attachments (
+ media_id text COLLATE "C" PRIMARY KEY,
+ owner_subject text COLLATE "C" NOT NULL,
+ post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
+ position bigint NOT NULL DEFAULT 0,
+ filename text COLLATE "C",
+ mime text COLLATE "C" NOT NULL,
+ size_bytes bigint NOT NULL,
+ url text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE TABLE post_reactions (
+ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+ subject_id text COLLATE "C" NOT NULL,
+ reaction text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (post_id, subject_id)
+);
+
+CREATE TABLE post_pastes (
+ post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+ paste_id bigint NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
+ position bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (post_id, paste_id)
+);
+
+CREATE TABLE space_chat_rooms (
+ space_id bigint PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,
+ room_id text COLLATE "C",
+ room_slug text COLLATE "C" NOT NULL,
+ room_name text COLLATE "C" NOT NULL,
+ room_kind text COLLATE "C" NOT NULL DEFAULT 'community',
+ room_visibility text COLLATE "C" NOT NULL DEFAULT 'public',
+ attached_by text COLLATE "C",
+ attached_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE TABLE relay_mappings (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ space_id bigint NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ direction text COLLATE "C" NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),
+ webhook_url_ref text COLLATE "C" NOT NULL,
+ enabled bigint NOT NULL DEFAULT 1,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ discord_channel_id text COLLATE "C",
+ discord_thread_id text COLLATE "C",
+ inbound bigint NOT NULL DEFAULT 0,
+ UNIQUE (space_id, direction, webhook_url_ref)
+);
+
+CREATE TABLE relay_deliveries (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
+ post_id bigint REFERENCES posts(id) ON DELETE CASCADE,
+ mapping_id bigint NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,
+ action text COLLATE "C" NOT NULL DEFAULT 'create' CHECK(action IN ('create', 'edit', 'delete')),
+ dedupe_key text COLLATE "C" UNIQUE NOT NULL,
+ source text COLLATE "C" NOT NULL DEFAULT 'direct' CHECK(source IN ('direct', 'events')),
+ event_id text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed', 'dropped', 'skipped')),
+ attempts bigint NOT NULL DEFAULT 0,
+ next_attempt_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ last_status bigint,
+ last_error text COLLATE "C",
+ delivered_at text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE TABLE relay_message_map (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ platform text COLLATE "C" NOT NULL DEFAULT 'discord' CHECK(platform IN ('discord')),
+ mapping_id bigint NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,
+ direction text COLLATE "C" NOT NULL CHECK(direction IN ('out', 'in')),
+ local_type text COLLATE "C" NOT NULL CHECK(local_type IN ('thread', 'post')),
+ local_id bigint NOT NULL,
+ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
+ external_channel_id text COLLATE "C" NOT NULL,
+ external_thread_id text COLLATE "C",
+ external_message_id text COLLATE "C" NOT NULL,
+ external_webhook_id text COLLATE "C",
+ external_deleted_at text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ UNIQUE (platform, external_message_id),
+ UNIQUE (platform, mapping_id, local_type, local_id)
+);
+
+CREATE TABLE relay_cursors (
+ name text COLLATE "C" PRIMARY KEY,
+ cursor bigint NOT NULL,
+ latest_seq bigint,
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE TABLE relay_inbound_failures (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ mapping_id bigint REFERENCES relay_mappings(id) ON DELETE SET NULL,
+ event text COLLATE "C" NOT NULL,
+ external_channel_id text COLLATE "C",
+ external_message_id text COLLATE "C",
+ external_author_id text COLLATE "C",
+ error text COLLATE "C" NOT NULL,
+ dismissed_at text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+
+CREATE TABLE pulse_items (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ source_service text COLLATE "C" NOT NULL,
+ source_type text COLLATE "C" NOT NULL,
+ source_id text COLLATE "C" NOT NULL,
+ title text COLLATE "C" NOT NULL,
+ url text COLLATE "C" NOT NULL,
+ actor_subject text COLLATE "C",
+ origin text COLLATE "C" NOT NULL CHECK(origin IN ('user', 'ai', 'system')),
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public' CHECK(visibility IN ('public')),
+ occurred_at text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ UNIQUE (source_service, source_type, source_id)
+);
+
+CREATE TABLE game_progress (
+ subject_id text COLLATE "C" PRIMARY KEY,
+ level bigint NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE import_hold (
+ source_type text COLLATE "C" NOT NULL,
+ source_id text COLLATE "C" NOT NULL,
+ reason text COLLATE "C" NOT NULL,
+ detail text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (source_type, source_id, reason)
+);
+
+CREATE TABLE profile_module_pushes (
+ subject_id text COLLATE "C" PRIMARY KEY,
+ hash text COLLATE "C" NOT NULL,
+ pushed_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE TABLE account_data_events (
+ id text COLLATE "C" PRIMARY KEY,
+ kind text COLLATE "C" NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ outcome text COLLATE "C",
+ sent_at text COLLATE "C",
+ applied_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
+);
+
+CREATE TABLE network_blocks (
+ blocker_subject text COLLATE "C" NOT NULL,
+ blocked_subject text COLLATE "C" NOT NULL,
+ active bigint NOT NULL,
+ revision bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (blocker_subject, blocked_subject)
+);
+
+CREATE INDEX idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL;
+CREATE INDEX idx_pastes_owner ON pastes(owner_subject, created_at DESC);
+CREATE INDEX idx_pastes_forked ON pastes(forked_from);
+CREATE INDEX idx_paste_comments_paste ON paste_comments(paste_id, parent_id);
+CREATE INDEX idx_paste_visits_last ON paste_visits(last_at);
+CREATE INDEX idx_subject_projection_username ON subject_projection(lower(username));
+CREATE INDEX idx_comments_thread ON comments(thread_id, parent_id, id);
+CREATE INDEX idx_comments_parent ON comments(parent_id, id);
+CREATE INDEX idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL;
+CREATE INDEX idx_threads_author ON threads(author_subject, created_at DESC);
+CREATE INDEX idx_posts_thread ON posts(thread_id, id);
+CREATE INDEX idx_posts_author ON posts(author_subject, created_at DESC);
+CREATE INDEX idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;
+CREATE INDEX idx_post_reactions_post ON post_reactions(post_id, reaction);
+CREATE INDEX idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);
+CREATE INDEX idx_relay_message_map_thread ON relay_message_map(thread_id);
+CREATE INDEX idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);
+CREATE INDEX idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);
+CREATE UNIQUE INDEX idx_comment_threads_access ON comment_threads(access_id);
+CREATE UNIQUE INDEX idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL;
+CREATE INDEX idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL;
+CREATE INDEX idx_relay_deliveries_thread ON relay_deliveries(thread_id, post_id);
+CREATE INDEX idx_relay_mappings_channel ON relay_mappings(discord_channel_id) WHERE discord_channel_id IS NOT NULL;
+CREATE INDEX idx_network_blocks_blocked ON network_blocks(blocked_subject, active);
+
+-- server/search/documents.js: what was last pushed to OpenVibe.Search per thread and paste
+CREATE TABLE search_doc_pushes (
+ type text COLLATE "C" NOT NULL,
+ id text COLLATE "C" NOT NULL,
+ hash text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ deleted bigint NOT NULL DEFAULT 0,
+ pushed_at text COLLATE "C" DEFAULT ov_now(),
+ PRIMARY KEY (type, id)
+);
+
+-- openvibe-sdk/auth createPgRevocationStore: Network's per-person token cutoffs (revocationSchema('token_revocations'))
+CREATE TABLE IF NOT EXISTS token_revocations (
+ subject_id text COLLATE "C" PRIMARY KEY,
+ valid_after_ms bigint NOT NULL,
+ reason text,
+ updated_at bigint NOT NULL
+);
+
+-- Rows the SQLite boot seeded
+INSERT INTO space_groups (id, slug, name, description, position) OVERRIDING SYSTEM VALUE VALUES
+ (1, 'openvibe', 'OpenVibe', 'The network itself: talk, help, requests and what is coming next.', 1),
+ (2, 'community', 'Community', 'What the people of OpenVibe make, and everything else.', 2);
+SELECT setval(pg_get_serial_sequence('space_groups', 'id'), (SELECT MAX(id) FROM space_groups));
+INSERT INTO spaces (id, slug, name, description, visibility, created_by, members_only_owner, thread_kind, style, votes, reactions, group_id, parent_id, position) OVERRIDING SYSTEM VALUE VALUES
+ (1, 'general', 'General', 'Anything about OpenVibe and the people on it.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 1, NULL, 1),
+ (2, 'feedback', 'Feedback', 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.', 'public', 'system', NULL, 'request', 'feed', 1, 1, 1, NULL, 3),
+ (3, 'showcase', 'Showcase', 'Show what you made: streams, clips, art, code, tools.', 'public', 'system', NULL, 'discussion', 'feed', 1, 1, 2, NULL, 1),
+ (4, 'roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', NULL, 'roadmap', 'feed', 1, 1, 1, NULL, 4),
+ (5, 'help', 'Help', 'Stuck on something? Ask here: streaming, tools, your account, anything on OpenVibe.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 1, NULL, 2),
+ (6, 'off-topic', 'Off-topic', 'Anything that is not about OpenVibe.', 'public', 'system', NULL, 'discussion', 'forum', 0, 1, 2, NULL, 2);
+SELECT setval(pg_get_serial_sequence('spaces', 'id'), (SELECT MAX(id) FROM spaces));
+INSERT INTO categories (id, space_id, slug, name, description, position) OVERRIDING SYSTEM VALUE VALUES
+ (1, 2, 'ideas', 'Ideas', 'Something new, or something better.', 1),
+ (2, 2, 'bugs', 'Bugs', 'Something is broken or wrong.', 2),
+ (3, 2, 'questions', 'Questions', 'How do I…? Why does…?', 3),
+ (4, 4, 'launches', 'New sites', 'Sites that open next.', 1),
+ (5, 4, 'features', 'Features', 'New things on sites that are already open.', 2),
+ (6, 4, 'platform', 'Under the hood', 'Accounts, safety, reliability and the shared systems every site uses.', 3);
+SELECT setval(pg_get_serial_sequence('categories', 'id'), (SELECT MAX(id) FROM categories));
+
+-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
+CREATE TABLE IF NOT EXISTS community_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 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 event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
@@ -16,15 +16,33 @@
@@ -16,15 +16,33 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
+ "iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
},
},
"engines": {
"engines": {
"node": ">=20.0.0"
"node": ">=20.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",
@@ -243,6 +261,15 @@
@@ -243,6 +261,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",
@@ -325,6 +352,15 @@
@@ -325,6 +352,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",
@@ -770,6 +806,49 @@
@@ -770,6 +806,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",
@@ -834,12 +913,24 @@
@@ -834,12 +913,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",
@@ -1057,9 +1148,9 @@
@@ -1057,9 +1148,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.71.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
- "integrity": "sha512-BLS6gP/MKuwhvTdWO8UYUaOyhmf7ni0cQ8r7150o57R5TrCnzdWQEVESxfMFCGgSbm2wdKtdZwTID69nxD14Tg==",
+ "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",
@@ -1073,18 +1164,38 @@
@@ -1073,18 +1164,38 @@
}
}
},
},
"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.21.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",
+ "integrity": "sha512-4gB+Lsj6Df1ztxI2ZNvPGUzXpSNV3FsEOP2VkcQ1hQIKdUdwH1BFEC16aQIwOrugmhe1U/CSmCWpPVb9bFu1ag==",
"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": {
- "version": "1.25.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
- "integrity": "sha512-oqZ+PvljrWi8IEbtg6bDYai17ZV2sALFx/ezMptniELSr10TgFUiGUQt+b1IWwiHv0T2Y1U2iSrkirJxMNgjSw==",
+ "version": "1.28.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
+ "integrity": "sha512-WLd11lDG75veVi7mFwngquK2nqIQV86Z14Ad/xE2dYvyMT5mhKJxxxlKUoskpdyLeap0ucMvzzS8Tg7vPGwYoQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1105,6 +1216,134 @@
@@ -1105,6 +1216,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",
@@ -1228,6 +1467,27 @@
@@ -1228,6 +1467,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",
@@ -1443,6 +1703,21 @@
@@ -1443,6 +1703,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",
@@ -1578,6 +1853,15 @@
@@ -1578,6 +1853,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"
+ }
}
}
}
}
}
}
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
"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",
- "n-1:record": "node scripts/n-1-record.js"
+ "n-1:record": "node scripts/n-1-record.js",
+ "test:pg": "COMMUNITY_TEST_STORE=pg node test/run.js"
},
},
"keywords": [
"keywords": [
"openvibe",
"openvibe",
@@ -26,12 +27,17 @@
@@ -26,12 +27,17 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
+ "iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
+ "pg": "^8.23.0"
},
},
"engines": {
"engines": {
"node": ">=20.0.0"
"node": ">=20.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
deleted file mode 100755
deleted file mode 100755
@@ -1,108 +0,0 @@
@@ -1,108 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-/**
- * Import OpenVibe.Live's own VOD and clip comments into Community comment threads.
- *
- * node scripts/import-live-comments.js --live-db <live.db> # dry run (default)
- * node scripts/import-live-comments.js --live-db <live.db> --apply --backup <file.db> # write
- *
- * Options:
- * --live-db <file> Live's database, opened read-only (required)
- * --community-db <file> Community's database (default: COMMUNITY_DB_PATH, as the server)
- * --apply write; without it nothing is written and the report shows what would be
- * --backup <file> required with --apply: an online backup of Community's database is written
- * here (must not exist yet) and checked before anything changes
- * --live-links-only name authors from Live's linked_accounts only (no Network call)
- *
- * Reads the same environment as the server (.env / /etc/openvibe/community.env):
- * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the
- * client needs identity.subject.resolve on openvibe.network). Safe to re-run: the ledger
- * (legacy_id_map live/comment) skips rows already imported. See server/comments/live-import.js.
- *
- * Exit codes: 0 done (or dry run finished), 2 refused (bad options, backup failed, reconciliation
- * failed), 1 anything else.
- */
-const fs = require('fs');
-const path = require('path');
-
-function usage(msg, code = msg ? 2 : 0) {
- if (msg) console.error(msg);
- console.error('usage: node scripts/import-live-comments.js --live-db <live.db> [--community-db <community.db>] [--apply --backup <file>] [--live-links-only]');
- process.exit(code);
-}
-
-function parseArgs(argv) {
- const out = { liveDb: null, communityDb: null, apply: false, backup: null, liveLinksOnly: false };
- const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };
- for (let i = 0; i < argv.length; i++) {
- const a = argv[i];
- if (a === '--live-db') out.liveDb = value(++i, a);
- else if (a === '--community-db') out.communityDb = value(++i, a);
- else if (a === '--backup') out.backup = value(++i, a);
- else if (a === '--apply') out.apply = true;
- else if (a === '--dry-run') out.apply = false;
- else if (a === '--live-links-only') out.liveLinksOnly = true;
- else if (a === '-h' || a === '--help') usage();
- else usage(`unknown argument ${a}`);
- }
- if (!out.liveDb) usage('missing --live-db');
- if (out.apply && !out.backup) usage('--apply needs --backup <file>: a backup of Community\'s database is taken first');
- return out;
-}
-
-async function main() {
- const args = parseArgs(process.argv.slice(2));
- const Database = require('better-sqlite3');
- const config = require('../server/config');
- const { openDb } = require('../server/db');
- const { importLiveComments, formatReport, LiveImportError } = require('../server/comments/live-import');
-
- if (!fs.existsSync(args.liveDb)) usage(`no Live database at ${args.liveDb}`);
- const communityPath = args.communityDb || config.dbPath;
- if (!fs.existsSync(communityPath)) usage(`no Community database at ${communityPath}`);
- // Opening runs the additive schema migrations; a dry run must not be what applies them.
- {
- const probe = new Database(communityPath, { readonly: true, fileMustExist: true });
- const cols = new Set(probe.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));
- probe.close();
- if (!cols.has('edited_at')) usage(`Community's database at ${communityPath} predates this importer: deploy and restart Community first`);
- }
-
- const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });
- const db = openDb(communityPath);
- try {
- if (args.apply) {
- const target = path.resolve(args.backup);
- if (fs.existsSync(target)) usage(`${target} exists already; pick a new backup file`);
- await db.backup(target);
- const check = new Database(target, { readonly: true, fileMustExist: true });
- const ok = check.pragma('integrity_check', { simple: true });
- const counts = check.prepare('SELECT (SELECT COUNT(*) FROM comments) AS comments, (SELECT COUNT(*) FROM comment_threads) AS threads').get();
- check.close();
- if (ok !== 'ok') { console.error(`Refused: the backup at ${target} failed its integrity check (${ok})`); process.exit(2); }
- console.log(`Backup: ${target} (integrity ok; ${counts.comments} comments in ${counts.threads} threads)`);
- }
-
- let resolveLegacy = null;
- if (!args.liveLinksOnly) {
- const { createNetworkIdentity } = require('../server/identity/network');
- const network = createNetworkIdentity({ config, db });
- // A dry run writes nothing at all, not even the display cache.
- resolveLegacy = (system, ids) => network.resolveLegacy(system, ids, { cache: args.apply });
- }
- const report = await importLiveComments(db, liveDb, {
- resolveLegacy, dryRun: !args.apply, source: path.basename(args.liveDb),
- });
- console.log(formatReport(report));
- if (!args.apply) console.log('\nNothing was written. Re-run with --apply --backup <file> to import.');
- } catch (err) {
- if (err instanceof LiveImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }
- throw err;
- } finally {
- liveDb.close();
- db.close();
- }
-}
-
-main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,72 +0,0 @@
@@ -1,72 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-/**
- * Import OpenVibe.Media's paste export into Community's database.
- *
- * node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff 2026-08-20T03:00:26Z] [--ambiguous-from 2026-08-17T21:10:29Z]
- *
- * Reads the same environment as the server (.env / /etc/openvibe/community.env):
- * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the
- * client needs identity.subject.resolve). Safe to re-run: see server/pastes/importer.js.
- *
- * Exit codes: 0 imported (or dry run finished), 2 bundle refused, 1 anything else.
- */
-const fs = require('fs');
-const path = require('path');
-
-function usage(msg) {
- if (msg) console.error(msg);
- console.error('usage: node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff <ISO time>] [--ambiguous-from <ISO time>|none]');
- process.exit(msg ? 1 : 0);
-}
-
-function parseArgs(argv) {
- const out = { file: null, dryRun: false, cutoff: null, ambiguousFrom: undefined };
- for (let i = 0; i < argv.length; i++) {
- const a = argv[i];
- if (a === '--dry-run') out.dryRun = true;
- else if (a === '--id-fix-cutoff') { out.cutoff = argv[++i]; if (!out.cutoff) usage('--id-fix-cutoff needs a value'); }
- else if (a.startsWith('--id-fix-cutoff=')) out.cutoff = a.slice('--id-fix-cutoff='.length);
- else if (a === '--ambiguous-from') { const v = argv[++i]; if (!v) usage('--ambiguous-from needs a value'); out.ambiguousFrom = v === 'none' ? null : v; }
- else if (a === '-h' || a === '--help') usage();
- else if (a.startsWith('-')) usage(`unknown option ${a}`);
- else if (!out.file) out.file = a;
- else usage(`unexpected argument ${a}`);
- }
- if (!out.file) usage('missing <bundle.json>');
- return out;
-}
-
-async function main() {
- const args = parseArgs(process.argv.slice(2));
- const config = require('../server/config');
- const { openDb } = require('../server/db');
- const { createNetworkIdentity } = require('../server/identity/network');
- const { importBundle, formatReport, ImportError, DEFAULT_CUTOFF } = require('../server/pastes/importer');
-
- let bundle;
- try { bundle = JSON.parse(fs.readFileSync(args.file, 'utf8')); }
- catch (err) { console.error(`Cannot read ${args.file}: ${err.message}`); process.exit(2); }
-
- const db = openDb(config.dbPath);
- const network = createNetworkIdentity({ config, db });
- try {
- const report = await importBundle(db, bundle, {
- // A dry run writes nothing at all, not even the display cache.
- resolveLegacy: (system, list) => network.resolveLegacy(system, list, { cache: !args.dryRun }),
- cutoff: args.cutoff || DEFAULT_CUTOFF,
- ...(args.ambiguousFrom !== undefined ? { ambiguousFrom: args.ambiguousFrom } : {}),
- dryRun: args.dryRun,
- source: `${bundle.app || 'media'}:${path.basename(args.file)}`,
- });
- console.log(formatReport(report));
- } catch (err) {
- if (err instanceof ImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }
- throw err;
- } finally {
- db.close();
- }
-}
-
-main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,66 +0,0 @@
@@ -1,66 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-/**
- * C-24: screenshot pastes stop naming their Media bytes by a legacy reference and name the media object.
- * Pastes imported from Live carry `legacy:live:paste:<Live id>`; Media knows the same screenshot as the
- * object whose legacy_ref is `legacy:live:paste:<slug>`. Pastes uploaded to the v1 file store before the
- * v2 switch carry `legacy:community:file:<key>`, looked up as they are.
- *
- * node scripts/export-legacy-refs.js --prefix legacy:live:paste: --out refs.json # on Media (read-only)
- * node scripts/migrate-screenshot-refs.js --map refs.json # dry run (default)
- * node scripts/migrate-screenshot-refs.js --map refs.json --apply --backup <file.db> # write
- *
- * --map <file> Media's export ({ objects: [{ legacy_ref, id, lifecycle_status }] }); several with commas
- * --apply write; without it nothing changes and the report shows what would
- * --backup <file> required with --apply: an online backup of Community's database, taken first
- * --db <file> Community's database (default: COMMUNITY_DB_PATH / the config)
- * --report <file> write the report as JSON too
- *
- * Only the reference changes (screenshot_url stays, so every page keeps working). Objects Media reports
- * deleted or failed, and pastes with no object, keep their legacy reference and are listed. Idempotent.
- * Exit codes: 0 done (or dry run), 2 refused (bad options, backup failed).
- */
-const fs = require('fs');
-const path = require('path');
-
-const args = process.argv.slice(2);
-const arg = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
-const has = (name) => args.includes(`--${name}`);
-
-async function main() {
- const mapFiles = String(arg('map', '')).split(',').filter(Boolean);
- const apply = has('apply');
- const backup = arg('backup');
- if (!mapFiles.length || (apply && !backup)) { console.error('usage: migrate-screenshot-refs.js --map <refs.json>[,<more>] [--apply --backup <file.db>] [--db <file>] [--report <file>]'); return 2; }
- const byRef = new Map();
- for (const f of mapFiles) for (const o of JSON.parse(fs.readFileSync(f, 'utf8')).objects || []) byRef.set(o.legacy_ref, o);
-
- const { openDb } = require('../server/db');
- const dbPath = path.resolve(arg('db', process.env.COMMUNITY_DB_PATH || require('../server/config').dbPath));
- const db = openDb(dbPath);
- const rows = db.prepare("SELECT id, slug, media_ref FROM pastes WHERE type = 'screenshot' AND media_ref LIKE 'legacy:%' ORDER BY id").all();
- const report = { db: dbPath, apply, candidates: rows.length, migrated: 0, would_migrate: 0, no_object: [], not_ready: [], changes: [] };
- const plan = [];
- for (const r of rows) {
- const key = /^legacy:live:paste:/.test(r.media_ref) ? `legacy:live:paste:${r.slug}` : r.media_ref;
- const o = byRef.get(key);
- if (!o) { report.no_object.push({ slug: r.slug, media_ref: r.media_ref }); continue; }
- if (!['ready', 'archived'].includes(o.lifecycle_status) || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(o.id)) { report.not_ready.push({ slug: r.slug, media_ref: r.media_ref, object: o.id, status: o.lifecycle_status }); continue; }
- plan.push({ id: r.id, slug: r.slug, from: r.media_ref, to: o.id });
- }
- report.changes = plan.slice(0, 20);
- if (!apply) report.would_migrate = plan.length;
- else {
- try { await db.backup(path.resolve(backup)); } catch (err) { console.error(`[migrate-screenshot-refs] backup failed: ${err.message}`); return 2; }
- const set = db.prepare("UPDATE pastes SET media_ref = ? WHERE id = ? AND media_ref = ?");
- report.migrated = db.transaction(() => plan.reduce((n, p) => n + set.run(p.to, p.id, p.from).changes, 0))();
- report.backup = path.resolve(backup);
- }
- const summary = { ...report, no_object: report.no_object.length, not_ready: report.not_ready.length, changes: undefined };
- console.log(JSON.stringify(summary, null, 1));
- if (arg('report')) fs.writeFileSync(arg('report'), JSON.stringify(report, null, 1));
- db.close();
- return 0;
-}
-
-main().then((code) => process.exit(code), (err) => { console.error(err); process.exit(1); });
new file mode 100755
new file mode 100755
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Community's SQLite database (COMMUNITY_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 config = require('../server/config');
+const { MIGRATIONS } = require('../server/db');
+
+const TABLES = {};
+
+if (require.main === module) {
+ runSqliteMigration({ service: 'community', 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 };
@@ -98,7 +98,20 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -98,7 +98,20 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
await server.close();
await server.close();
server = null;
server = null;
- // 3. The schema N-1 left, and the SQL it runs on it.
+ // 3. The schema N-1 left, and the SQL it runs on it. A release on PostgreSQL (ADR-035) records its migration files
+ // instead: N must keep every one of them unchanged and only add (the database's own ledger refuses an edit).
+ const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
+ const clients = svc.clientReleases ? svc.clientReleases() : null;
+ const migDir = path.join(wt.dir, 'migrations');
+ if (fs.existsSync(migDir)) {
+ const migrations = fs.readdirSync(migDir).filter((f) => f.endsWith('.sql')).sort()
+ .map((name) => ({ name, sha256: require('crypto').createHash('sha256').update(fs.readFileSync(path.join(migDir, name))).digest('hex') }));
+ fs.mkdirSync(OUT, { recursive: true });
+ fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
+ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, engine: 'postgresql', migrations, statements: [] }, null, 1)}\n`);
+ console.log(` postgresql: ${migrations.length} migration file(s); wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
+ return;
+ }
const db = new Database(dbPath);
const db = new Database(dbPath);
const schema = h.schemaDDL(db);
const schema = h.schemaDDL(db);
const ledger = {};
const ledger = {};
@@ -117,8 +130,6 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -117,8 +130,6 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);
console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);
fs.mkdirSync(OUT, { recursive: true });
fs.mkdirSync(OUT, { recursive: true });
- const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
- const clients = svc.clientReleases ? svc.clientReleases() : null;
fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* (/internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall behind;
* (/internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall behind;
* they also carry token cutoffs and account deletions). Pages keep their per-address form limits.
* they also carry token cutoffs and account deletions). Pages keep their per-address form limits.
*/
*/
-const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const config = require('./config');
const config = require('./config');
const FIRST_PARTY = /^svc:/;
const FIRST_PARTY = /^svc:/;
@@ -54,7 +54,7 @@ function serviceItself(req) {
@@ -54,7 +54,7 @@ function serviceItself(req) {
* limits(name, own) middleware for one app, plus limits.reads(name): the defaults on every GET/HEAD of
* limits(name, own) middleware for one app, plus limits.reads(name): the defaults on every GET/HEAD of
* a router (its writes set their own limits per route). opts.limits and opts.now are for tests.
* a router (its writes set their own limits per route). opts.limits and opts.now are for tests.
*/
*/
-function createActorLimits({ limits = null, now = () => Date.now(), registry = null, log = console } = {}) {
+function createActorLimits({ limits = null, now = () => Date.now(), registry = null, log = console, valkey = null } = {}) {
const refused = registry
const refused = registry
? registry.counter({ name: 'community_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'community_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -62,6 +62,8 @@ function createActorLimits({ limits = null, now = () => Date.now(), registry = n
@@ -62,6 +62,8 @@ function createActorLimits({ limits = null, now = () => Date.now(), registry = n
limits: limits || { minute: config.limits.minute, hour: config.limits.hour },
limits: limits || { minute: config.limits.minute, hour: config.limits.hour },
actor,
actor,
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}`);
@@ -41,7 +41,7 @@ const pages = require('./render/pages');
@@ -41,7 +41,7 @@ const pages = require('./render/pages');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');
const { createAuthClient, createAuthRoutes, optionalAuth } = require('./auth/routes');
const { createPastesProxy } = require('./pastes/proxy');
const { createPastesProxy } = require('./pastes/proxy');
-const { openDb, getDb } = require('./db');
+const { getDb } = require('./db');
const { createNetworkIdentity } = require('./identity/network');
const { createNetworkIdentity } = require('./identity/network');
const { createViewerResolver } = require('./identity/viewer');
const { createViewerResolver } = require('./identity/viewer');
const v1 = require('./http/v1');
const v1 = require('./http/v1');
@@ -67,7 +67,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -67,7 +67,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -115,11 +115,13 @@ function createApp(opts = {}) {
@@ -115,11 +115,13 @@ function createApp(opts = {}) {
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
// ── Community's database, identity, and what lives in it in every mode ──
// ── Community's database, identity, and what lives in it in every mode ──
- const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
+ const db = opts.db || getDb();
const network = opts.network || createNetworkIdentity({ config, db });
const network = opts.network || createNetworkIdentity({ config, db });
// Network's per-person token cutoffs (network.user.token_valid_after): sign out everywhere, password
// Network's per-person token cutoffs (network.user.token_valid_after): sign out everywhere, password
// changes and bans refuse older tokens here at once (WS-B task 4).
// changes and bans refuse older tokens here at once (WS-B task 4).
- const revocations = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });
+ // Cutoffs are read into memory before the app serves: isRevoked() is synchronous on every signed-in request.
+ const revocations = require('openvibe-sdk/auth').createPgRevocationStore(db, { table: 'token_revocations' });
+ await revocations.load();
const viewers = createViewerResolver({ auth, config, network, revocations });
const viewers = createViewerResolver({ auth, config, network, revocations });
const pulse = createPulse({ db, network, config });
const pulse = createPulse({ db, network, config });
const relay = opts.relay || createDiscordRelay({
const relay = opts.relay || createDiscordRelay({
@@ -140,7 +142,7 @@ function createApp(opts = {}) {
@@ -140,7 +142,7 @@ function createApp(opts = {}) {
}),
}),
gateway: relayInbound && config.discordRelay.inbound ? createDiscordGateway({
gateway: relayInbound && config.discordRelay.inbound ? createDiscordGateway({
token: config.discordRelay.botToken, url: config.discordRelay.gatewayUrl,
token: config.discordRelay.botToken, url: config.discordRelay.gatewayUrl,
- onDispatch: (type, data, ctx) => relayInbound.handle(type, data, ctx),
+ onDispatch: async (type, data, ctx) => await relayInbound.handle(type, data, ctx),
...(opts.gatewayOptions || {}),
...(opts.gatewayOptions || {}),
}) : null,
}) : null,
inbound: relayInbound,
inbound: relayInbound,
@@ -169,7 +171,7 @@ function createApp(opts = {}) {
@@ -169,7 +171,7 @@ function createApp(opts = {}) {
const media = opts.media || {
const media = opts.media || {
tokens: files.tokens,
tokens: files.tokens,
async upload({ buffer, filename, mime, owner = null }) {
async upload({ buffer, filename, mime, owner = null }) {
- if (!mediaObjects.configured) return files.upload({ buffer, filename, mime });
+ if (!mediaObjects.configured) return await files.upload({ buffer, filename, mime });
const o = await mediaObjects.uploadImage({ buffer, mime, filename, owner, kind: 'screenshot', source: 'community.paste' });
const o = await mediaObjects.uploadImage({ buffer, mime, filename, owner, kind: 'screenshot', source: 'community.paste' });
return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };
return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };
},
},
@@ -193,7 +195,11 @@ function createApp(opts = {}) {
@@ -193,7 +195,11 @@ function createApp(opts = {}) {
// Per-actor limits for every API router below (server/actor-limits.js), after each one resolves its
// Per-actor limits for every API router below (server/actor-limits.js), after each one resolves its
// viewer; the per-address /api/ limit stays in front. opts.actorLimits: { limits, now } (tests).
// viewer; the per-address /api/ limit stays in front. opts.actorLimits: { limits, now } (tests).
- const limits = createActorLimits({ registry: metrics.registry, ...(opts.actorLimits || {}) });
+ // Valkey (ADR-035): the per-actor limit counters, shared across processes; opts.valkey for tests (null: none).
+ const valkey = opts.valkey !== undefined ? opts.valkey
+ : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix }) : null);
+ app.locals.valkey = valkey;
+ const limits = createActorLimits({ registry: metrics.registry, valkey, ...(opts.actorLimits || {}) });
// ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──
// ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
@@ -227,7 +233,7 @@ function createApp(opts = {}) {
@@ -227,7 +233,7 @@ function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
// Readiness reports what is actually served: 503 only without the database; Network key,
// Readiness reports what is actually served: 503 only without the database; Network key,
// Live (live mode) and Media (community mode) failures degrade (server/observability.js).
// Live (live mode) and Media (community mode) failures degrade (server/observability.js).
- const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl });
+ const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl, valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Static assets (content-hashed ?v= → immutable) ───────
// ── Static assets (content-hashed ?v= → immutable) ───────
@@ -307,10 +313,10 @@ function createApp(opts = {}) {
@@ -307,10 +313,10 @@ function createApp(opts = {}) {
// The store is the authority: raw text is served here, the screenshot link is the stored
// The store is the authority: raw text is served here, the screenshot link is the stored
// Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)
// Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)
const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
- app.get('/p/:slug/raw', withUser, (req, res) => {
+ app.get('/p/:slug/raw', withUser, async (req, res) => {
if (!SLUG_RE.test(req.params.slug)) return notFound(res);
if (!SLUG_RE.test(req.params.slug)) return notFound(res);
try {
try {
- const out = app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
+ const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
if (out.redirect) return res.redirect(302, out.redirect);
if (out.redirect) return res.redirect(302, out.redirect);
res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');
res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');
res.type('text/plain; charset=utf-8').send(out.content);
res.type('text/plain; charset=utf-8').send(out.content);
@@ -320,9 +326,9 @@ function createApp(opts = {}) {
@@ -320,9 +326,9 @@ function createApp(opts = {}) {
throw err;
throw err;
}
}
});
});
- app.get('/p/:slug/screenshot', withUser, (req, res) => {
+ app.get('/p/:slug/screenshot', withUser, async (req, res) => {
if (!SLUG_RE.test(req.params.slug)) return notFound(res);
if (!SLUG_RE.test(req.params.slug)) return notFound(res);
- try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
+ try { res.redirect(302, await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
catch (err) {
catch (err) {
if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
if (err.status === 404) return notFound(res);
if (err.status === 404) return notFound(res);
@@ -41,15 +41,15 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,
@@ -41,15 +41,15 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,
// Opening a thread writes a row the first time: a person opens at most one a second.
// Opening a thread writes a row the first time: a person opens at most one a second.
router.post('/threads/resolve', serviceCap(WRITE), limits('community.thread.resolve', { minute: 60, hour: 1200 }), resolveLimiter || passAnon, jsonBody,
router.post('/threads/resolve', serviceCap(WRITE), limits('community.thread.resolve', { minute: 60, hour: 1200 }), resolveLimiter || passAnon, jsonBody,
- run((req) => service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
- router.get('/threads/:id', read, run((req) => service.get(req.viewer, req.params.id, req.query)));
+ run(async (req) => await service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
+ router.get('/threads/:id', read, run(async (req) => await service.get(req.viewer, req.params.id, req.query)));
router.post('/threads/:id/comments', serviceCap(WRITE), limits('community.comment.create', { minute: 20, hour: 300 }), anonWriteLimiter || passAnon, jsonBody,
router.post('/threads/:id/comments', serviceCap(WRITE), limits('community.comment.create', { minute: 20, hour: 300 }), anonWriteLimiter || passAnon, jsonBody,
- run((req) => service.add(req.viewer, req.params.id, req.body || {}), 201));
- router.put('/threads/:id/visibility', serviceCap(MOD), moderate, jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));
- router.get('/:commentId', read, run((req) => service.getComment(req.viewer, req.params.commentId)));
- router.patch('/:commentId', serviceCap(WRITE), limits('community.comment.edit', { minute: 30, hour: 300 }), jsonBody, run((req) => service.edit(req.viewer, req.params.commentId, req.body || {})));
- router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), moderate, run((req) => service.remove(req.viewer, req.params.commentId)));
- router.post('/:commentId/votes', serviceCap(WRITE), limits('community.comment.vote', { minute: 120, hour: 1200 }), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));
+ run(async (req) => await service.add(req.viewer, req.params.id, req.body || {}), 201));
+ router.put('/threads/:id/visibility', serviceCap(MOD), moderate, jsonBody, run(async (req) => await service.setVisibility(req.viewer, req.params.id, req.body || {})));
+ router.get('/:commentId', read, run(async (req) => await service.getComment(req.viewer, req.params.commentId)));
+ router.patch('/:commentId', serviceCap(WRITE), limits('community.comment.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await service.edit(req.viewer, req.params.commentId, req.body || {})));
+ router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), moderate, run(async (req) => await service.remove(req.viewer, req.params.commentId)));
+ router.post('/:commentId/votes', serviceCap(WRITE), limits('community.comment.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await service.vote(req.viewer, req.params.commentId, req.body || {})));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
deleted file mode 100644
deleted file mode 100644
@@ -1,220 +0,0 @@
@@ -1,220 +0,0 @@
-'use strict';
-
-/**
- * OpenVibe.Live's own VOD and clip comments (live.db `comments`) → Community comment threads.
- *
- * Live used to keep comments on VODs and clips in its own table; they are now the Community
- * threads of EntityRef { service: 'live', type: 'vod'|'clip', id } (Live's /api/comments is an
- * adapter over them). This moves the old rows across, once, keeping their times and text.
- *
- * Every Live row ends up in exactly one bucket, so the report reconciles:
- *
- * read = imported (new this run + already imported by an earlier run) + held + excluded
- *
- * imported the comment is in its thread; legacy_id_map ('live','comment',<live id>) → the
- * Community comment id is the ledger, so a second run imports nothing twice
- * held the author could not be named as a Network subject (unmapped_author), or two
- * sources named different subjects (ambiguous_author), or its parent comment is held
- * (parent_held). Recorded in import_hold ('live_comment', <live id>, reason) and
- * listed; a later run imports them once the mapping exists (and clears the hold)
- * excluded deleted on Live (deleted), a reply under a deleted comment (parent_deleted, Live
- * no longer showed those), a parent that is missing or on other content (orphan),
- * an empty message (empty) or a target type other than vod/clip (bad_target)
- *
- * Authors: Live's linked_accounts(service='network').subject_id (written from verified Network
- * tokens) and the Network's identity map (resolve-batch, system 'live', type 'user'). Either one
- * is enough; when both answer they must agree.
- *
- * Replies nest one level in Community: a Live reply to a reply joins its top-level comment.
- * A row Live marked edited (updated_at later than created_at) keeps that time as edited_at.
- * Live's database is only read. With dryRun (the default) everything runs in a transaction that
- * is rolled back, so the report is exactly what --apply would do.
- */
-const store = require('./store');
-
-const SOURCE_TYPE = 'live_comment';
-const LEDGER = ['live', 'comment'];
-const IMPORTER = 'import:live-comments';
-
-class LiveImportError extends Error {}
-
-function readLive(liveDb) {
- const tables = new Set(liveDb.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
- if (!tables.has('comments')) throw new LiveImportError('the Live database has no comments table');
- const rows = liveDb.prepare(`SELECT id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at
- FROM comments ORDER BY id`).all();
- const links = new Map();
- if (tables.has('linked_accounts')) {
- const cols = new Set(liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name));
- if (cols.has('subject_id')) {
- for (const r of liveDb.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL").all()) {
- links.set(String(r.user_id), r.subject_id);
- }
- }
- }
- return { rows, links };
-}
-
-/** Live's SQLite times are UTC 'YYYY-MM-DD HH:MM:SS'; keep them in that form. */
-function sqlTime(v) {
- if (!v) return null;
- const s = String(v);
- if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s;
- const d = new Date(/^\d{4}-\d{2}-\d{2}T/.test(s) || /Z$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);
- return Number.isNaN(d.getTime()) ? null : d.toISOString().replace('T', ' ').slice(0, 19);
-}
-
-const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-/**
- * opts.resolveLegacy(system, ids) → Map(id → { subject: { type, id } } | null) (Network), or null
- * to rely on Live's own links only. opts.dryRun (default true). opts.source names the run.
- */
-async function importLiveComments(db, liveDb, { resolveLegacy = null, dryRun = true, source = 'live.db' } = {}) {
- const { rows, links } = readLive(liveDb);
- const byId = new Map(rows.map((r) => [r.id, r]));
-
- // ── Classify: excluded rows first (they need no author) ──
- const excluded = new Map(); // live id → reason
- const rootOf = new Map(); // live id → top-level live id
- for (const r of rows) {
- if (r.is_deleted) { excluded.set(r.id, 'deleted'); continue; }
- if (!['vod', 'clip'].includes(r.content_type) || !(Number(r.content_id) > 0)) { excluded.set(r.id, 'bad_target'); continue; }
- if (!String(r.message || '').trim()) { excluded.set(r.id, 'empty'); continue; }
- let cur = r, reason = null;
- const seen = new Set([r.id]);
- while (cur.parent_id != null) {
- const p = byId.get(cur.parent_id);
- if (!p || seen.has(p.id) || p.content_type !== r.content_type || p.content_id !== r.content_id) { reason = 'orphan'; break; }
- if (p.is_deleted) { reason = 'parent_deleted'; break; }
- seen.add(p.id);
- cur = p;
- }
- if (reason) excluded.set(r.id, reason);
- else rootOf.set(r.id, cur.id);
- }
-
- // ── Authors → subjects ──
- const userIds = [...new Set(rows.filter((r) => rootOf.has(r.id)).map((r) => String(r.user_id)))];
- let network = new Map();
- if (resolveLegacy && userIds.length) network = await resolveLegacy('live', userIds);
- const subjectFor = new Map(); // live user id → { subject } | { reason, detail }
- for (const uid of userIds) {
- const hit = network.get(uid);
- const fromNet = hit && hit.subject && hit.subject.type === 'user' && USER_SUBJECT.test(hit.subject.id) ? hit.subject.id : null;
- const fromLive = USER_SUBJECT.test(String(links.get(uid) || '')) ? links.get(uid) : null;
- if (fromNet && fromLive && fromNet !== fromLive) subjectFor.set(uid, { reason: 'ambiguous_author', detail: `network says ${fromNet}, Live's link says ${fromLive}` });
- else if (fromNet || fromLive) subjectFor.set(uid, { subject: fromNet || fromLive });
- else subjectFor.set(uid, { reason: 'unmapped_author', detail: `live user ${uid} has no Network subject` });
- }
-
- const report = {
- source, dry_run: !!dryRun, read: rows.length,
- imported: 0, imported_new: 0, imported_before: 0, held: 0, excluded: 0,
- excluded_by_reason: {}, held_rows: [], threads: [], reconciled: false,
- };
-
- const ledger = db.prepare('SELECT target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?');
- const ledgerGet = (liveId) => {
- const hit = ledger.get(LEDGER[0], LEDGER[1], String(liveId));
- return hit ? Number(hit.target_id) : null;
- };
- const ledgerSet = db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, 'comment', ?)
- ON CONFLICT(source_system, source_type, source_id) DO NOTHING`);
- const hold = db.prepare(`INSERT INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)
- ON CONFLICT(source_type, source_id, reason) DO UPDATE SET detail = excluded.detail`);
- const unhold = db.prepare('DELETE FROM import_hold WHERE source_type = ? AND source_id = ?');
- const insert = db.prepare(`INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, edited_at)
- VALUES (?, ?, ?, NULL, 'user', ?, ?, ?, ?)`);
-
- const apply = () => {
- const touchedThreads = new Set();
- const touchedParents = new Set();
- const threadIds = new Map(); // 'vod:12' → thread row
- const threadFor = (r) => {
- const key = `${r.content_type}:${r.content_id}`;
- if (!threadIds.has(key)) {
- const { thread } = store.resolveThread(db, { service: 'live', type: r.content_type, id: String(r.content_id) }, { createdBy: IMPORTER });
- threadIds.set(key, thread);
- }
- return threadIds.get(key);
- };
- const state = new Map(); // live id → 'imported' | 'held'
-
- // Top-level comments first, then replies (whose parent must be placed already).
- const ordered = rows.filter((r) => rootOf.has(r.id)).sort((a, b) => ((a.parent_id == null ? 0 : 1) - (b.parent_id == null ? 0 : 1)) || (a.id - b.id));
- for (const r of ordered) {
- const root = rootOf.get(r.id);
- const who = subjectFor.get(String(r.user_id));
- let heldReason = who.subject ? null : who.reason;
- let detail = who.subject ? null : who.detail;
- if (!heldReason && root !== r.id && state.get(root) === 'held') { heldReason = 'parent_held'; detail = `parent comment ${root} is held`; }
- if (heldReason) {
- state.set(r.id, 'held');
- unhold.run(SOURCE_TYPE, String(r.id)); // one current reason per row
- hold.run(SOURCE_TYPE, String(r.id), heldReason, JSON.stringify({ detail, content_type: r.content_type, content_id: r.content_id, user_id: r.user_id }));
- report.held_rows.push({ live_id: r.id, reason: heldReason, content: `${r.content_type}/${r.content_id}`, live_user_id: r.user_id, detail });
- continue;
- }
- const thread = threadFor(r);
- if (!report.threads.includes(thread.access_id)) report.threads.push(thread.access_id);
- const known = ledgerGet(r.id);
- if (known) { state.set(r.id, 'imported'); report.imported_before++; continue; }
- const parentCommunityId = root === r.id ? null : ledgerGet(root);
- const created = sqlTime(r.created_at) || sqlTime(new Date().toISOString());
- const updated = sqlTime(r.updated_at) || created;
- const edited = updated > created ? updated : null;
- const info = insert.run(thread.id, parentCommunityId, who.subject, String(r.message).trim(), created, updated, edited);
- ledgerSet.run(LEDGER[0], LEDGER[1], String(r.id), String(info.lastInsertRowid));
- unhold.run(SOURCE_TYPE, String(r.id));
- state.set(r.id, 'imported');
- report.imported_new++;
- touchedThreads.add(thread.id);
- if (parentCommunityId) touchedParents.add(parentCommunityId);
- }
-
- // Counters from the rows (the store keeps them in step for live writes; here we set them once).
- for (const id of touchedThreads) {
- db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id, id);
- }
- for (const id of touchedParents) {
- db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(id, id);
- }
-
- report.imported = report.imported_new + report.imported_before;
- report.held = report.held_rows.length;
- for (const reason of excluded.values()) report.excluded_by_reason[reason] = (report.excluded_by_reason[reason] || 0) + 1;
- report.excluded = excluded.size;
- report.reconciled = report.read === report.imported + report.held + report.excluded;
- if (!report.reconciled) throw new LiveImportError(`reconciliation failed: read ${report.read} ≠ imported ${report.imported} + held ${report.held} + excluded ${report.excluded}`);
- if (!dryRun) {
- db.prepare('INSERT INTO migration_runs (source, finished_at, report) VALUES (?, CURRENT_TIMESTAMP, ?)').run(`live-comments:${source}`, JSON.stringify(report));
- }
- };
-
- const ROLLBACK = Symbol('dry run');
- try {
- db.transaction(() => { apply(); if (dryRun) throw ROLLBACK; })();
- } catch (err) {
- if (err !== ROLLBACK) throw err;
- }
- return report;
-}
-
-function formatReport(r) {
- const lines = [];
- lines.push(`Live comments → Community threads (${r.dry_run ? 'DRY RUN — nothing was written' : 'APPLIED'}) from ${r.source}`);
- lines.push(` read ${r.read}`);
- lines.push(` imported ${r.imported} (new ${r.imported_new}, already imported ${r.imported_before})`);
- lines.push(` held ${r.held}`);
- lines.push(` excluded ${r.excluded}${Object.keys(r.excluded_by_reason).length ? ` (${Object.entries(r.excluded_by_reason).map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}`);
- lines.push(` check read ${r.read} = imported ${r.imported} + held ${r.held} + excluded ${r.excluded} → ${r.reconciled ? 'OK' : 'MISMATCH'}`);
- lines.push(` threads ${r.threads.length}`);
- if (r.held_rows.length) {
- lines.push(' held rows (not imported; re-run once the author has a Network subject):');
- for (const h of r.held_rows) lines.push(` live comment ${h.live_id} ${h.reason} on ${h.content} live user ${h.live_user_id}${h.detail ? ` — ${h.detail}` : ''}`);
- }
- return lines.join('\n');
-}
-
-module.exports = { importLiveComments, formatReport, LiveImportError, SOURCE_TYPE };
@@ -115,15 +115,15 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -115,15 +115,15 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
const all = [];
const all = [];
for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
const projections = await authors.projectionsFor(all.map((c) => c.author_subject));
const projections = await authors.projectionsFor(all.map((c) => c.author_subject));
- const votes = myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
+ const votes = await myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v)));
return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v)));
}
}
/** A thread by the id this viewer may use: the access id for anyone, the sequential id for services only. */
/** A thread by the id this viewer may use: the access id for anyone, the sequential id for services only. */
- function threadById(v, id) {
+ async function threadById(v, id) {
const s = String(id == null ? '' : id);
const s = String(id == null ? '' : id);
- if (ACCESS_ID_RE.test(s)) return store.getThreadByAccessId(db, s);
- if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return store.getThread(db, Number(s));
+ if (ACCESS_ID_RE.test(s)) return await store.getThreadByAccessId(db, s);
+ if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return await store.getThread(db, Number(s));
return null;
return null;
}
}
@@ -132,23 +132,23 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -132,23 +132,23 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');
if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');
return t;
return t;
}
}
- const visibleThread = (v, id) => visible(v, threadById(v, id));
+ const visibleThread = async (v, id) => visible(v, await threadById(v, id));
- function visibleComment(v, id) {
- const c = /^\d{1,15}$/.test(String(id)) ? store.getComment(db, Number(id)) : null;
+ async function visibleComment(v, id) {
+ const c = /^\d{1,15}$/.test(String(id)) ? await store.getComment(db, Number(id)) : null;
if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');
if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');
- return { comment: c, thread: visible(v, store.getThread(db, c.thread_id)) };
+ return { comment: c, thread: visible(v, await store.getThread(db, c.thread_id)) };
}
}
/** Community's own entities must exist (and be visible) before anyone opens a thread on them. */
/** Community's own entities must exist (and be visible) before anyone opens a thread on them. */
- function checkCommunityRef(ref) {
+ async function checkCommunityRef(ref) {
if (ref.service !== 'community') return;
if (ref.service !== 'community') return;
if (ref.type === 'paste') {
if (ref.type === 'paste') {
if (!pastesLocal) return; // PASTES_AUTHORITY=live: Live owns them, nothing to check here
if (!pastesLocal) return; // PASTES_AUTHORITY=live: Live owns them, nothing to check here
- const p = pasteStore.getBySlug(db, ref.id);
+ const p = await pasteStore.getBySlug(db, ref.id);
if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');
if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');
} else if (ref.type === 'post') {
} else if (ref.type === 'post') {
- const ok = /^\d{1,15}$/.test(ref.id) && db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id
+ const ok = /^\d{1,15}$/.test(ref.id) && await db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id
WHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'`).get(Number(ref.id));
WHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'`).get(Number(ref.id));
if (!ok) fail(404, 'ref.not_found', 'No such post');
if (!ok) fail(404, 'ref.not_found', 'No such post');
}
}
@@ -157,10 +157,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -157,10 +157,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
const signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);
const signedInOnly = (t) => (SIGNED_IN_ONLY[t.ref_service] || []).includes(t.ref_type);
/** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */
/** Who owns a thread's Community entity (a paste's owner, a forum post's author), or null. */
- function entityOwner(t) {
+ async function entityOwner(t) {
if (t.ref_service !== 'community') return null;
if (t.ref_service !== 'community') return null;
- if (t.ref_type === 'paste' && pastesLocal) { const p = pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }
- if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }
+ if (t.ref_type === 'paste' && pastesLocal) { const p = await pasteStore.getBySlug(db, t.ref_id); return p ? p.owner_subject || null : null; }
+ if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) { const r = await db.prepare('SELECT author_subject FROM posts WHERE id = ?').get(Number(t.ref_id)); return r ? r.author_subject || null : null; }
return null;
return null;
}
}
@@ -182,7 +182,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -182,7 +182,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
SIGNED_IN_ONLY,
SIGNED_IN_ONLY,
/** POST /threads/resolve { ref } → { thread, created } */
/** POST /threads/resolve { ref } → { thread, created } */
- resolve(v, body = {}) {
+ async resolve(v, body = {}) {
const ref = body && body.ref;
const ref = body && body.ref;
const check = ref && typeof ref === 'object' ? contracts.validate('common.entity-ref@1', ref) : { valid: false };
const check = ref && typeof ref === 'object' ? contracts.validate('common.entity-ref@1', ref) : { valid: false };
if (!check.valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');
if (!check.valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');
@@ -190,10 +190,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -190,10 +190,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
const types = BROWSER_REF_TYPES[ref.service];
const types = BROWSER_REF_TYPES[ref.service];
if (!types || !types.includes(ref.type)) fail(403, 'ref.type_not_allowed', `Comments on ${ref.service}/${ref.type} can only be opened by that service`);
if (!types || !types.includes(ref.type)) fail(403, 'ref.type_not_allowed', `Comments on ${ref.service}/${ref.type} can only be opened by that service`);
}
}
- checkCommunityRef(ref);
+ await checkCommunityRef(ref);
// Labels are display caches; only a service's word is taken for them.
// Labels are display caches; only a service's word is taken for them.
const label = v.kind === 'service' && ref.label ? String(ref.label).slice(0, 200) : null;
const label = v.kind === 'service' && ref.label ? String(ref.label).slice(0, 200) : null;
- const { thread, created } = store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });
+ const { thread, created } = await store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });
return { thread: shapeThread(thread, v), created };
return { thread: shapeThread(thread, v), created };
},
},
@@ -203,17 +203,17 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -203,17 +203,17 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
* comment's replies instead.
* comment's replies instead.
*/
*/
async get(v, id, q = {}) {
async get(v, id, q = {}) {
- const t = visibleThread(v, id);
+ const t = await visibleThread(v, id);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
const after = cursorId(q.after);
const after = cursorId(q.after);
let rows, hasMore;
let rows, hasMore;
if (q.parent != null && q.parent !== '') {
if (q.parent != null && q.parent !== '') {
const parent = cursorId(q.parent);
const parent = cursorId(q.parent);
- const p = parent != null ? store.getComment(db, parent) : null;
+ const p = parent != null ? await store.getComment(db, parent) : null;
if (!p || p.thread_id !== t.id || p.parent_id) fail(404, 'comment.not_found', 'Comment not found');
if (!p || p.thread_id !== t.id || p.parent_id) fail(404, 'comment.not_found', 'Comment not found');
- ({ rows, hasMore } = store.listReplies(db, parent, { after, limit }));
+ ({ rows, hasMore } = await store.listReplies(db, parent, { after, limit }));
} else {
} else {
- ({ rows, hasMore } = store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));
+ ({ rows, hasMore } = await store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));
}
}
const comments = await shapeComments(rows, v, t);
const comments = await shapeComments(rows, v, t);
return {
return {
@@ -231,14 +231,14 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -231,14 +231,14 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
/** POST /threads/:id/comments { message, parent_id?, anon_name? } */
/** POST /threads/:id/comments { message, parent_id?, anon_name? } */
async add(v, id, body = {}) {
async add(v, id, body = {}) {
- const t = visibleThread(v, id);
+ const t = await visibleThread(v, id);
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
const message = cleanMessage(body.message);
const message = cleanMessage(body.message);
let parent = null;
let parent = null;
let parentId = null;
let parentId = null;
if (body.parent_id != null && body.parent_id !== '') {
if (body.parent_id != null && body.parent_id !== '') {
- parent = /^\d{1,15}$/.test(String(body.parent_id)) ? store.getComment(db, Number(body.parent_id)) : null;
+ parent = /^\d{1,15}$/.test(String(body.parent_id)) ? await store.getComment(db, Number(body.parent_id)) : null;
if (!parent || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'comment.invalid_parent', 'Invalid parent comment');
if (!parent || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'comment.invalid_parent', 'Invalid parent comment');
// One level of nesting: a reply to a reply joins the top-level comment's replies.
// One level of nesting: a reply to a reply joins the top-level comment's replies.
parentId = parent.parent_id || parent.id;
parentId = parent.parent_id || parent.id;
@@ -249,10 +249,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -249,10 +249,10 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
// Platform blocks: no reply to a comment whose author blocked you (the one answered, and the
// Platform blocks: no reply to a comment whose author blocked you (the one answered, and the
// top-level comment it joins), no comment on a paste or post whose owner blocked you.
// top-level comment it joins), no comment on a paste or post whose owner blocked you.
if (parent) {
if (parent) {
- const top = parent.parent_id ? store.getComment(db, parent.parent_id) : null;
- blocks.refuseIfBlocked(db, [parent.author_subject, top && top.author_subject], author, 'reply to this comment');
+ const top = parent.parent_id ? await store.getComment(db, parent.parent_id) : null;
+ await blocks.refuseIfBlocked(db, [parent.author_subject, top && top.author_subject], author, 'reply to this comment');
}
}
- blocks.refuseIfBlocked(db, [entityOwner(t)], author, `comment on this ${t.ref_type}`, 'its owner');
+ await blocks.refuseIfBlocked(db, [await entityOwner(t)], author, `comment on this ${t.ref_type}`, 'its owner');
if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');
if (!author && origin !== 'ai' && !moderator(v) && signedInOnly(t)) fail(401, 'auth.required', 'Sign in to comment here');
let anonName = null;
let anonName = null;
if (!author && origin !== 'ai') {
if (!author && origin !== 'ai') {
@@ -260,7 +260,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -260,7 +260,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
}
}
const key = author ? `s:${author}` : null;
const key = author ? `s:${author}` : null;
commentLimiter.check(key, message);
commentLimiter.check(key, message);
- const c = store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
+ const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
commentLimiter.record(key, message);
commentLimiter.record(key, message);
return { comment: (await shapeComments([c], v, t))[0] };
return { comment: (await shapeComments([c], v, t))[0] };
},
},
@@ -271,52 +271,52 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -271,52 +271,52 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
*/
*/
async getComment(v, commentId) {
async getComment(v, commentId) {
if (!v || v.kind !== 'service') fail(404, 'comment.not_found', 'Comment not found');
if (!v || v.kind !== 'service') fail(404, 'comment.not_found', 'Comment not found');
- const { comment: c, thread: t } = visibleComment(v, commentId);
+ const { comment: c, thread: t } = await visibleComment(v, commentId);
return { comment: (await shapeComments([c], v, t))[0], thread: shapeThread(t, v) };
return { comment: (await shapeComments([c], v, t))[0], thread: shapeThread(t, v) };
},
},
/** PATCH /:commentId { message } — the author only. */
/** PATCH /:commentId { message } — the author only. */
async edit(v, commentId, body = {}) {
async edit(v, commentId, body = {}) {
if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to edit a comment');
if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to edit a comment');
- const { comment: c, thread: t } = visibleComment(v, commentId);
+ const { comment: c, thread: t } = await visibleComment(v, commentId);
if (!(c.author_subject && c.author_subject === v.subject)) fail(403, 'comment.not_yours', 'Only the author can edit a comment');
if (!(c.author_subject && c.author_subject === v.subject)) fail(403, 'comment.not_yours', 'Only the author can edit a comment');
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
if (t.visibility === 'locked' && !moderator(v)) fail(403, 'thread.locked', 'This comment thread is locked');
const message = cleanMessage(body.message);
const message = cleanMessage(body.message);
- const row = message === c.message ? c : store.editComment(db, c.id, message);
+ const row = message === c.message ? c : await store.editComment(db, c.id, message);
return { comment: (await shapeComments([row], v, t))[0] };
return { comment: (await shapeComments([row], v, t))[0] };
},
},
/** DELETE /comments/:id — the author or a moderator. */
/** DELETE /comments/:id — the author or a moderator. */
- remove(v, commentId) {
- const { comment: c } = visibleComment(v, commentId);
+ async remove(v, commentId) {
+ const { comment: c } = await visibleComment(v, commentId);
if (!(v && v.subject) && !moderator(v)) fail(401, 'auth.required', 'Sign in to delete a comment');
if (!(v && v.subject) && !moderator(v)) fail(401, 'auth.required', 'Sign in to delete a comment');
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');
if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');
- store.softDeleteComment(db, c.id, v.subject || v.service || null);
+ await store.softDeleteComment(db, c.id, v.subject || v.service || null);
return { ok: true, id: c.id };
return { ok: true, id: c.id };
},
},
/** POST /comments/:id/votes { value: 1 | -1 | 0 } */
/** POST /comments/:id/votes { value: 1 | -1 | 0 } */
- vote(v, commentId, body = {}) {
+ async vote(v, commentId, body = {}) {
const value = parseVote(body.value);
const value = parseVote(body.value);
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to vote');
if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to vote');
- const { comment: c, thread: t } = visibleComment(v, commentId);
+ const { comment: c, thread: t } = await visibleComment(v, commentId);
if (t.visibility !== 'public') fail(403, 'thread.locked', 'This comment thread is locked');
if (t.visibility !== 'public') fail(403, 'thread.locked', 'This comment thread is locked');
voteLimiter.check(`s:${v.subject}`);
voteLimiter.check(`s:${v.subject}`);
- const out = applyVote(db, 'comment', c.id, v.subject, value);
+ const out = await applyVote(db, 'comment', c.id, v.subject, value);
voteLimiter.record(`s:${v.subject}`);
voteLimiter.record(`s:${v.subject}`);
return { comment_id: c.id, ...out };
return { comment_id: c.id, ...out };
},
},
/** PUT /threads/:id/visibility { visibility } — moderators. */
/** PUT /threads/:id/visibility { visibility } — moderators. */
- setVisibility(v, id, body = {}) {
+ async setVisibility(v, id, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
const visibility = body.visibility;
const visibility = body.visibility;
if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
- const t = threadById(v, id);
+ const t = await threadById(v, id);
if (!t) fail(404, 'thread.not_found', 'Comment thread not found');
if (!t) fail(404, 'thread.not_found', 'Comment thread not found');
- return { thread: shapeThread(store.setThreadVisibility(db, t.id, visibility), v) };
+ return { thread: shapeThread(await store.setThreadVisibility(db, t.id, visibility), v) };
},
},
};
};
}
}
@@ -11,17 +11,17 @@
@@ -11,17 +11,17 @@
*/
*/
const { newThreadAccessId } = require('../db');
const { newThreadAccessId } = require('../db');
-function getThread(db, id) {
- return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
+async function getThread(db, id) {
+ return await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
}
}
/** A thread by the unguessable handle browsers use (server/db.js: access_id). */
/** A thread by the unguessable handle browsers use (server/db.js: access_id). */
-function getThreadByAccessId(db, accessId) {
- return db.prepare('SELECT * FROM comment_threads WHERE access_id = ?').get(String(accessId)) || null;
+async function getThreadByAccessId(db, accessId) {
+ return await db.prepare('SELECT * FROM comment_threads WHERE access_id = ?').get(String(accessId)) || null;
}
}
-function getThreadByRef(db, ref) {
- return db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?')
+async function getThreadByRef(db, ref) {
+ return await db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?')
.get(ref.service, ref.type, String(ref.id)) || null;
.get(ref.service, ref.type, String(ref.id)) || null;
}
}
@@ -30,26 +30,26 @@ function getThreadByRef(db, ref) {
@@ -30,26 +30,26 @@ function getThreadByRef(db, ref) {
* constraint decides, INSERT … ON CONFLICT DO NOTHING never fails, and the row is read back.
* constraint decides, INSERT … ON CONFLICT DO NOTHING never fails, and the row is read back.
* A label (from a service) refreshes the cached one. → { thread, created }
* A label (from a service) refreshes the cached one. → { thread, created }
*/
*/
-function resolveThread(db, ref, { label = null, createdBy = null } = {}) {
- return db.transaction(() => {
- const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?)
- ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING`)
+async function resolveThread(db, ref, { label = null, createdBy = null } = {}) {
+ return await db.tx(async () => {
+ const info = await db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING RETURNING id`)
.run(ref.service, ref.type, String(ref.id), label, createdBy, newThreadAccessId());
.run(ref.service, ref.type, String(ref.id), label, createdBy, newThreadAccessId());
if (!info.changes && label) {
if (!info.changes && label) {
- db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?')
+ await db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = ov_now() WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?')
.run(label, ref.service, ref.type, String(ref.id), label);
.run(label, ref.service, ref.type, String(ref.id), label);
}
}
- return { thread: getThreadByRef(db, ref), created: info.changes > 0 };
- })();
+ return { thread: await getThreadByRef(db, ref), created: info.changes > 0 };
+ });
}
}
-function setThreadVisibility(db, id, visibility) {
- db.prepare('UPDATE comment_threads SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(visibility, id);
- return getThread(db, id);
+async function setThreadVisibility(db, id, visibility) {
+ await db.prepare('UPDATE comment_threads SET visibility = ?, updated_at = ov_now() WHERE id = ?').run(visibility, id);
+ return await getThread(db, id);
}
}
-function getComment(db, id) {
- return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;
+async function getComment(db, id) {
+ return await db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;
}
}
/**
/**
@@ -57,13 +57,13 @@ function getComment(db, id) {
@@ -57,13 +57,13 @@ function getComment(db, id) {
* not deleted and does not burn, or a live post in a public space outside members-only. Anything else (a
* not deleted and does not burn, or a live post in a public space outside members-only. Anything else (a
* Live VOD or clip may be private; only Live knows) is not, so its comment events stay internal.
* Live VOD or clip may be private; only Live knows) is not, so its comment events stay internal.
*/
*/
-function refIsPublic(db, t) {
+async function refIsPublic(db, t) {
if (!t || t.ref_service !== 'community') return false;
if (!t || t.ref_service !== 'community') return false;
if (t.ref_type === 'paste') {
if (t.ref_type === 'paste') {
- return !!db.prepare("SELECT 1 FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0").get(String(t.ref_id));
+ return !!await db.prepare("SELECT 1 FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0").get(String(t.ref_id));
}
}
if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) {
if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) {
- return !!db.prepare(`SELECT 1 FROM posts p JOIN threads th ON th.id = p.thread_id JOIN spaces s ON s.id = th.space_id
+ return !!await db.prepare(`SELECT 1 FROM posts p JOIN threads th ON th.id = p.thread_id JOIN spaces s ON s.id = th.space_id
WHERE p.id = ? AND p.deleted_at IS NULL AND th.deleted_at IS NULL AND s.visibility = 'public'
WHERE p.id = ? AND p.deleted_at IS NULL AND th.deleted_at IS NULL AND s.visibility = 'public'
AND s.members_only_owner IS NULL AND th.members_only_owner IS NULL`).get(Number(t.ref_id));
AND s.members_only_owner IS NULL AND th.members_only_owner IS NULL`).get(Number(t.ref_id));
}
}
@@ -71,35 +71,35 @@ function refIsPublic(db, t) {
@@ -71,35 +71,35 @@ function refIsPublic(db, t) {
}
}
/** Insert a comment and keep the thread's and parent's counters in step. */
/** Insert a comment and keep the thread's and parent's counters in step. */
-function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {
- return db.transaction(() => {
- const info = db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?)')
+async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {
+ return await db.tx(async () => {
+ const info = await db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?) RETURNING id')
.run(thread_id, parent_id, author_subject, anon_name, origin, message);
.run(thread_id, parent_id, author_subject, anon_name, origin, message);
- db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
- if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
- const comment = getComment(db, info.lastInsertRowid);
- const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
- if (cthread) require('../events').commentCreated(comment, cthread, { itemPublic: refIsPublic(db, cthread) }); // community.comment.created
+ await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);
+ if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
+ const comment = await getComment(db, info.lastInsertRowid);
+ const cthread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
+ if (cthread) await require('../events').commentCreated(comment, cthread, { itemPublic: await refIsPublic(db, cthread) }); // community.comment.created
return comment;
return comment;
- })();
+ });
}
}
/** The author's new text; edited_at records that it changed. → the row */
/** The author's new text; edited_at records that it changed. → the row */
-function editComment(db, id, message) {
- db.prepare('UPDATE comments SET message = ?, edited_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(message, id);
- return getComment(db, id);
+async function editComment(db, id, message) {
+ await db.prepare('UPDATE comments SET message = ?, edited_at = ov_now(), updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(message, id);
+ return await getComment(db, id);
}
}
/** Soft delete; counters recomputed from the rows. → changes */
/** Soft delete; counters recomputed from the rows. → changes */
-function softDeleteComment(db, id, deletedBy = null) {
- return db.transaction(() => {
- const c = getComment(db, id);
+async function softDeleteComment(db, id, deletedBy = null) {
+ return await db.tx(async () => {
+ const c = await getComment(db, id);
if (!c || c.deleted_at) return 0;
if (!c || c.deleted_at) return 0;
- db.prepare("UPDATE comments SET deleted_at = CURRENT_TIMESTAMP, deleted_by = ?, message = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(deletedBy, id);
- db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(c.thread_id, c.thread_id);
- if (c.parent_id) db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
+ await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);
+ await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);
+ if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
return 1;
return 1;
- })();
+ });
}
}
/**
/**
@@ -109,23 +109,23 @@ function softDeleteComment(db, id, deletedBy = null) {
@@ -109,23 +109,23 @@ function softDeleteComment(db, id, deletedBy = null) {
* Deleted top-level comments appear only while they still have replies (tombstones).
* Deleted top-level comments appear only while they still have replies (tombstones).
* → { rows, hasMore } — each row carries .replies (oldest first) and .reply_count
* → { rows, hasMore } — each row carries .replies (oldest first) and .reply_count
*/
*/
-function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {
+async function listTopLevel(db, threadId, { after = null, sort = 'old', limit = 30, replyLimit = 20 } = {}) {
const newest = sort === 'new';
const newest = sort === 'new';
const params = [threadId];
const params = [threadId];
let cursor = '';
let cursor = '';
if (after != null) { cursor = newest ? 'AND id < ?' : 'AND id > ?'; params.push(after); }
if (after != null) { cursor = newest ? 'AND id < ?' : 'AND id > ?'; params.push(after); }
- const rows = db.prepare(`SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ${cursor}
+ const rows = await db.prepare(`SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ${cursor}
ORDER BY id ${newest ? 'DESC' : 'ASC'} LIMIT ?`).all(...params, limit + 1);
ORDER BY id ${newest ? 'DESC' : 'ASC'} LIMIT ?`).all(...params, limit + 1);
const hasMore = rows.length > limit;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
if (hasMore) rows.pop();
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
- for (const r of rows) r.replies = replies.all(r.id, replyLimit);
+ for (const r of rows) r.replies = await replies.all(r.id, replyLimit);
return { rows, hasMore };
return { rows, hasMore };
}
}
/** Replies of one comment after a cursor (for comments with more replies than the first page shows). */
/** Replies of one comment after a cursor (for comments with more replies than the first page shows). */
-function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
- const rows = db.prepare(`SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ${after != null ? 'AND id > ?' : ''} ORDER BY id ASC LIMIT ?`)
+async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
+ const rows = await db.prepare(`SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ${after != null ? 'AND id > ?' : ''} ORDER BY id ASC LIMIT ?`)
.all(...(after != null ? [parentId, after] : [parentId]), limit + 1);
.all(...(after != null ? [parentId, after] : [parentId]), limit + 1);
const hasMore = rows.length > limit;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
if (hasMore) rows.pop();
@@ -60,8 +60,13 @@ module.exports = {
@@ -60,8 +60,13 @@ module.exports = {
// 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.
// 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.
// 'community' — this site's own database is the authority (server/pastes/api.js).
// 'community' — this site's own database is the authority (server/pastes/api.js).
pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',
pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',
- // SQLite file for Community's own data: comments, the forum, Pulse, the relay's bookkeeping,
- // and pastes once authority=community.
+ // PostgreSQL (ADR-035) for Community's own data: comments, the forum, Pulse, the relay's bookkeeping, and pastes
+ // once authority=community. DATABASE_URL serves (PgBouncer); DATABASE_DIRECT_URL migrates (owner). Without them,
+ // development uses an embedded PGlite database in data/pglite.
+ db: { url: process.env.DATABASE_URL || '', directUrl: process.env.DATABASE_DIRECT_URL || '' },
+ // Valkey (ADR-035): per-actor limit counters shared across processes; without it they count in this process.
+ valkey: { url: process.env.VALKEY_URL || '', prefix: process.env.VALKEY_PREFIX || 'ov:community:' },
+ // The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
dbPath: process.env.COMMUNITY_DB_PATH || './data/community.db',
dbPath: process.env.COMMUNITY_DB_PATH || './data/community.db',
// Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)
// Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * Community's own database (SQLite, better-sqlite3).
+ * Community's own database: PostgreSQL (ADR-035, roadmap WS-X2). The schema is migrations/NNNN_*.sql, applied at boot.
*
*
* Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit
* Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit
* history, likes and comments live here. Typed comment threads, the forum (spaces, threads,
* history, likes and comments live here. Typed comment threads, the forum (spaces, threads,
@@ -9,581 +9,59 @@
@@ -9,581 +9,59 @@
* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
* (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
* of what the Network says about them.
* of what the Network says about them.
*
*
- * Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.
+ * Timestamps stay SQLite's text ('YYYY-MM-DD HH:MM:SS', UTC): the migration defines ov_now(), datetime() and
+ * julianday() with SQLite's behaviour, and ov_hot() for the forum's hot rank.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
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');
-// relay_deliveries' columns: the table in SCHEMA, and migrate()'s one-time rebuild of the older shape.
-const RELAY_DELIVERIES_COLUMNS = `
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
- mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,
- action TEXT NOT NULL DEFAULT 'create' CHECK(action IN ('create', 'edit', 'delete')),
- dedupe_key TEXT UNIQUE NOT NULL,
- source TEXT NOT NULL DEFAULT 'direct' CHECK(source IN ('direct', 'events')),
- event_id TEXT,
- status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed', 'dropped', 'skipped')),
- attempts INTEGER NOT NULL DEFAULT 0,
- next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- last_status INTEGER,
- last_error TEXT,
- delivered_at DATETIME,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-`;
-
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS pastes (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- slug TEXT UNIQUE NOT NULL,
- owner_subject TEXT,
- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),
- type TEXT NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),
- title TEXT NOT NULL DEFAULT 'Untitled',
- content TEXT,
- language TEXT DEFAULT 'text',
- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),
- screenshot_url TEXT,
- media_ref TEXT,
- stream_ref TEXT, -- JSON EntityRef, e.g. {"service":"live","type":"stream","id":"123"}
- metadata TEXT, -- JSON
- burn_after_read INTEGER NOT NULL DEFAULT 0,
- forked_from INTEGER REFERENCES pastes(id) ON DELETE SET NULL,
- pinned INTEGER NOT NULL DEFAULT 0,
- views INTEGER NOT NULL DEFAULT 0,
- unique_views INTEGER NOT NULL DEFAULT 0,
- copies INTEGER NOT NULL DEFAULT 0,
- likes INTEGER NOT NULL DEFAULT 0,
- is_nsfw INTEGER NOT NULL DEFAULT 0,
- ai_summary TEXT,
- ai_tags TEXT, -- JSON array
- ai_analyzed_at DATETIME,
- revision INTEGER NOT NULL DEFAULT 1,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at DATETIME,
- legacy_media_id INTEGER UNIQUE,
- legacy_user_id INTEGER
-);
-CREATE INDEX IF NOT EXISTS idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL;
-CREATE INDEX IF NOT EXISTS idx_pastes_owner ON pastes(owner_subject, created_at DESC);
-CREATE INDEX IF NOT EXISTS idx_pastes_forked ON pastes(forked_from);
-
-CREATE TABLE IF NOT EXISTS paste_versions (
- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
- revision INTEGER NOT NULL,
- title TEXT,
- content TEXT,
- language TEXT,
- edited_by TEXT,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (paste_id, revision)
-);
-
-CREATE TABLE IF NOT EXISTS paste_likes (
- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
- subject_id TEXT NOT NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (paste_id, subject_id)
-);
-
-CREATE TABLE IF NOT EXISTS paste_comments (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
- author_subject TEXT,
- anon_name TEXT,
- parent_id INTEGER REFERENCES paste_comments(id) ON DELETE CASCADE,
- message TEXT NOT NULL,
- is_deleted INTEGER NOT NULL DEFAULT 0,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- legacy_media_id INTEGER UNIQUE
-);
-CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id, parent_id);
-
-CREATE TABLE IF NOT EXISTS paste_visits (
- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
- visitor TEXT NOT NULL,
- first_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- last_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- visits INTEGER NOT NULL DEFAULT 1,
- PRIMARY KEY (paste_id, visitor)
-);
-CREATE INDEX IF NOT EXISTS idx_paste_visits_last ON paste_visits(last_at);
-
-CREATE TABLE IF NOT EXISTS subject_projection (
- subject_id TEXT PRIMARY KEY,
- username TEXT,
- display_name TEXT,
- avatar_url TEXT,
- profile_color TEXT,
- refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-CREATE INDEX IF NOT EXISTS idx_subject_projection_username ON subject_projection(username COLLATE NOCASE);
-
-CREATE TABLE IF NOT EXISTS migration_runs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- source TEXT NOT NULL,
- started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- finished_at DATETIME,
- report TEXT
-);
-
-CREATE TABLE IF NOT EXISTS legacy_id_map (
- source_system TEXT NOT NULL,
- source_type TEXT NOT NULL,
- source_id TEXT NOT NULL,
- target_type TEXT NOT NULL,
- target_id TEXT NOT NULL,
- PRIMARY KEY (source_system, source_type, source_id)
-);
-
-CREATE TABLE IF NOT EXISTS comment_threads (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- ref_service TEXT NOT NULL,
- ref_type TEXT NOT NULL,
- ref_id TEXT NOT NULL,
- ref_label TEXT, -- cached display label from a service; never authoritative
- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')),
- comment_count INTEGER NOT NULL DEFAULT 0,
- created_by TEXT, -- subject or service principal that first resolved it
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- access_id TEXT, -- unguessable handle for browsers (migrate() fills old rows, unique index)
- UNIQUE (ref_service, ref_type, ref_id)
-);
-
-CREATE TABLE IF NOT EXISTS comments (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thread_id INTEGER NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
- parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE,
- author_subject TEXT,
- anon_name TEXT,
- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai')),
- message TEXT NOT NULL,
- score INTEGER NOT NULL DEFAULT 0,
- upvotes INTEGER NOT NULL DEFAULT 0,
- downvotes INTEGER NOT NULL DEFAULT 0,
- reply_count INTEGER NOT NULL DEFAULT 0,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at DATETIME,
- deleted_by TEXT,
- edited_at DATETIME -- the author last changed the text (migrate() adds it to older databases)
-);
-CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id, parent_id, id);
-CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id, id);
-
-CREATE TABLE IF NOT EXISTS comment_votes (
- comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
- subject_id TEXT NOT NULL,
- value INTEGER NOT NULL CHECK(value IN (-1, 1)),
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (comment_id, subject_id)
-);
-
-CREATE TABLE IF NOT EXISTS spaces (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- slug TEXT UNIQUE NOT NULL,
- name TEXT NOT NULL,
- description TEXT,
- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),
- created_by TEXT,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by) VALUES
- ('general', 'General', 'Anything about OpenVibe and the people on it.', 'public', 'system'),
- ('feedback', 'Feedback', 'Feature requests, bugs and ideas for every OpenVibe site.', 'public', 'system'),
- ('showcase', 'Showcase', 'Show what you made: streams, clips, art, code, tools.', 'public', 'system');
-
-CREATE TABLE IF NOT EXISTS threads (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
- slug TEXT NOT NULL,
- title TEXT NOT NULL,
- author_subject TEXT,
- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
- pinned INTEGER NOT NULL DEFAULT 0,
- locked INTEGER NOT NULL DEFAULT 0,
- score INTEGER NOT NULL DEFAULT 0,
- reply_count INTEGER NOT NULL DEFAULT 0,
- last_activity_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at DATETIME,
- UNIQUE (space_id, slug)
-);
-CREATE INDEX IF NOT EXISTS idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL;
-CREATE INDEX IF NOT EXISTS idx_threads_author ON threads(author_subject, created_at DESC);
-
-CREATE TABLE IF NOT EXISTS posts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- author_subject TEXT,
- origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),
- is_opening INTEGER NOT NULL DEFAULT 0,
- body_markdown TEXT NOT NULL,
- revision INTEGER NOT NULL DEFAULT 1,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at DATETIME
-);
-CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, id);
-CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_subject, created_at DESC);
-
-CREATE TABLE IF NOT EXISTS post_versions (
- post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
- revision INTEGER NOT NULL,
- body_markdown TEXT NOT NULL,
- edited_by TEXT,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (post_id, revision)
-);
-
-CREATE TABLE IF NOT EXISTS thread_votes (
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- subject_id TEXT NOT NULL,
- value INTEGER NOT NULL CHECK(value IN (-1, 1)),
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (thread_id, subject_id)
-);
-
-CREATE TABLE IF NOT EXISTS attachments (
- media_id TEXT PRIMARY KEY,
- owner_subject TEXT NOT NULL,
- post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
- position INTEGER NOT NULL DEFAULT 0,
- filename TEXT,
- mime TEXT NOT NULL,
- size_bytes INTEGER NOT NULL,
- url TEXT NOT NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-CREATE INDEX IF NOT EXISTS idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;
-
-CREATE TABLE IF NOT EXISTS space_groups (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- slug TEXT UNIQUE NOT NULL,
- name TEXT NOT NULL,
- description TEXT,
- position INTEGER NOT NULL DEFAULT 0
-);
-
-CREATE TABLE IF NOT EXISTS post_reactions (
- post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
- subject_id TEXT NOT NULL,
- reaction TEXT NOT NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (post_id, subject_id)
-);
-CREATE INDEX IF NOT EXISTS idx_post_reactions_post ON post_reactions(post_id, reaction);
-
-CREATE TABLE IF NOT EXISTS post_pastes (
- post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
- paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
- position INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (post_id, paste_id)
-);
-
-CREATE TABLE IF NOT EXISTS categories (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
- slug TEXT NOT NULL,
- name TEXT NOT NULL,
- description TEXT,
- position INTEGER NOT NULL DEFAULT 0,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE (space_id, slug)
-);
-
-CREATE TABLE IF NOT EXISTS space_chat_rooms (
- space_id INTEGER PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,
- room_id INTEGER,
- room_slug TEXT NOT NULL,
- room_name TEXT NOT NULL,
- room_kind TEXT NOT NULL DEFAULT 'community',
- room_visibility TEXT NOT NULL DEFAULT 'public',
- attached_by TEXT,
- attached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE TABLE IF NOT EXISTS relay_mappings (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
- direction TEXT NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),
- webhook_url_ref TEXT NOT NULL,
- enabled INTEGER NOT NULL DEFAULT 1,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE (space_id, direction, webhook_url_ref)
-);
-
-CREATE TABLE IF NOT EXISTS relay_deliveries (${RELAY_DELIVERIES_COLUMNS});
-CREATE INDEX IF NOT EXISTS idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);
-
-CREATE TABLE IF NOT EXISTS relay_message_map (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- platform TEXT NOT NULL DEFAULT 'discord' CHECK(platform IN ('discord')),
- mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,
- direction TEXT NOT NULL CHECK(direction IN ('out', 'in')),
- local_type TEXT NOT NULL CHECK(local_type IN ('thread', 'post')),
- local_id INTEGER NOT NULL,
- thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- external_channel_id TEXT NOT NULL,
- external_thread_id TEXT,
- external_message_id TEXT NOT NULL,
- external_webhook_id TEXT,
- external_deleted_at DATETIME,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE (platform, external_message_id),
- UNIQUE (platform, mapping_id, local_type, local_id)
-);
-CREATE INDEX IF NOT EXISTS idx_relay_message_map_thread ON relay_message_map(thread_id);
-
-CREATE TABLE IF NOT EXISTS relay_cursors (
- name TEXT PRIMARY KEY,
- cursor INTEGER NOT NULL,
- latest_seq INTEGER,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE TABLE IF NOT EXISTS relay_inbound_failures (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- mapping_id INTEGER REFERENCES relay_mappings(id) ON DELETE SET NULL,
- event TEXT NOT NULL,
- external_channel_id TEXT,
- external_message_id TEXT,
- external_author_id TEXT,
- error TEXT NOT NULL,
- dismissed_at DATETIME,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE TABLE IF NOT EXISTS pulse_items (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- source_service TEXT NOT NULL,
- source_type TEXT NOT NULL,
- source_id TEXT NOT NULL,
- title TEXT NOT NULL,
- url TEXT NOT NULL,
- actor_subject TEXT,
- origin TEXT NOT NULL CHECK(origin IN ('user', 'ai', 'system')),
- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public')),
- occurred_at DATETIME NOT NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE (source_service, source_type, source_id)
-);
-CREATE INDEX IF NOT EXISTS idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC);
-CREATE TABLE IF NOT EXISTS game_progress (
- subject_id TEXT PRIMARY KEY,
- level INTEGER NOT NULL,
- updated_at TEXT NOT NULL
-);
-CREATE INDEX IF NOT EXISTS idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC);
-
-CREATE TABLE IF NOT EXISTS import_hold (
- source_type TEXT NOT NULL,
- source_id TEXT NOT NULL,
- reason TEXT NOT NULL,
- detail TEXT,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (source_type, source_id, reason)
-);
-`;
-
-/**
- * SQL functions every connection needs (worker connections in tests too).
- *
- * ov_hot(score, age_hours) — the forum's "hot" rank. Deterministic: the same score and age
- * always give the same number, and callers pass one `now` for a whole page, so a page is
- * ranked against a single instant:
- *
- * hot = (score + 1) / (age_hours + 2) ^ 1.5
- *
- * +1 lets a thread nobody voted on yet still rank by age (instead of all tying at 0); +2 hours
- * keeps a brand-new thread from dividing by ~0; the 1.5 exponent is the Hacker News gravity.
- */
-function registerFunctions(db) {
- db.function('ov_hot', { deterministic: true }, (score, ageHours) => (Number(score || 0) + 1) / Math.pow(Math.max(Number(ageHours) || 0, 0) + 2, 1.5));
-}
-
-/** Open (creating the directory and schema if needed). ':memory:' works for tests. */
-function openDb(file) {
- if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
- const db = new Database(file);
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
- db.exec(SCHEMA);
- migrate(db);
- registerFunctions(db);
- return db;
-}
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
/**
/**
- * Additive changes to tables that already exist (CREATE TABLE IF NOT EXISTS never alters one).
- * Idempotent: every boot checks and fills in only what is missing.
- *
- * comment_threads.access_id — the unguessable handle browsers address a thread by (128 random
- * bits); the sequential id is for services only, so threads cannot be enumerated. Threads made
- * before the column existed get one here.
- * comments.edited_at — when the author last edited the text (null: never edited).
- * spaces.members_only_owner, threads.members_only_owner — members-only for a creator's OpenVibe.VIP
- * members (their Network subject); NULL: not gated. See forum/service.js.
+ * 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.
*/
*/
-function migrate(db) {
- const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));
- if (!cols.has('access_id')) db.exec('ALTER TABLE comment_threads ADD COLUMN access_id TEXT');
- const missing = db.prepare('SELECT id FROM comment_threads WHERE access_id IS NULL').all();
- if (missing.length) {
- const set = db.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?');
- db.transaction(() => { for (const r of missing) set.run(newThreadAccessId(), r.id); })();
+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 community)');
+ const dir = process.env.COMMUNITY_PGLITE_DIR || DEV_PGLITE; // tests that boot the real server give it a directory of its own
+ log.warn(`[Community] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'community', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
}
}
- db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');
- const commentCols = new Set(db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));
- if (!commentCols.has('edited_at')) db.exec('ALTER TABLE comments ADD COLUMN edited_at DATETIME');
- // Members-only (OpenVibe.VIP): the creator (usr_…) whose members may read and post.
- for (const table of ['spaces', 'threads']) {
- const c = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((x) => x.name));
- if (!c.has('members_only_owner')) db.exec(`ALTER TABLE ${table} ADD COLUMN members_only_owner TEXT`);
- }
- // Categories, feature requests and the roadmap (WS-J tasks 1 and 8; forum/service.js):
- // spaces.thread_kind what a new thread in the space is: discussion, request (votes and a status set
- // by staff) or roadmap (staff and the roadmap sync only; forum/roadmap.js)
- // threads.kind/status/category_id/external_key the thread's kind, its status for requests and roadmap
- // items, its category, and the roadmap item key it was synced from
- const spaceCols = new Set(db.prepare('PRAGMA table_info(spaces)').all().map((x) => x.name));
- if (!spaceCols.has('thread_kind')) db.exec("ALTER TABLE spaces ADD COLUMN thread_kind TEXT NOT NULL DEFAULT 'discussion'");
- const threadCols = new Set(db.prepare('PRAGMA table_info(threads)').all().map((x) => x.name));
- if (!threadCols.has('kind')) db.exec("ALTER TABLE threads ADD COLUMN kind TEXT NOT NULL DEFAULT 'discussion'");
- if (!threadCols.has('status')) db.exec('ALTER TABLE threads ADD COLUMN status TEXT');
- if (!threadCols.has('category_id')) db.exec('ALTER TABLE threads ADD COLUMN category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL');
- if (!threadCols.has('external_key')) db.exec('ALTER TABLE threads ADD COLUMN external_key TEXT');
- db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL;
- CREATE INDEX IF NOT EXISTS idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL;
- INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, thread_kind) VALUES
- ('roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', 'roadmap');
- UPDATE spaces SET thread_kind = 'request', description = 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.'
- WHERE slug = 'feedback' AND thread_kind = 'discussion' AND created_by = 'system';`);
- // Space styles (forum/service.js): 'feed' (subreddit-like: votes, hot/new/top) or 'forum' (vBulletin/SMF-like:
- // board index, topics by last post, author panels, quotes). Votes and ratings are per-space switches;
- // group_id and position place a space on the board index, parent_id makes it a child board.
- for (const [col, ddl] of [['style', "TEXT NOT NULL DEFAULT 'feed'"], ['votes', 'INTEGER NOT NULL DEFAULT 1'], ['reactions', 'INTEGER NOT NULL DEFAULT 1'],
- ['group_id', 'INTEGER REFERENCES space_groups(id) ON DELETE SET NULL'], ['parent_id', 'INTEGER REFERENCES spaces(id) ON DELETE SET NULL'], ['position', 'INTEGER NOT NULL DEFAULT 0']]) {
- if (!spaceCols.has(col)) db.exec(`ALTER TABLE spaces ADD COLUMN ${col} ${ddl}`);
- }
- if (!threadCols.has('views')) db.exec('ALTER TABLE threads ADD COLUMN views INTEGER NOT NULL DEFAULT 0');
- if (!threadCols.has('crosspost_of')) db.exec('ALTER TABLE threads ADD COLUMN crosspost_of INTEGER REFERENCES threads(id) ON DELETE SET NULL');
- // The first board index: two groups, the four original spaces placed once (a space staff moved is left alone),
- // and two new forum boards.
- db.exec(`INSERT OR IGNORE INTO space_groups (slug, name, description, position) VALUES
- ('openvibe', 'OpenVibe', 'The network itself: talk, help, requests and what is coming next.', 1),
- ('community', 'Community', 'What the people of OpenVibe make, and everything else.', 2);
- INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, style, votes) VALUES
- ('help', 'Help', 'Stuck on something? Ask here: streaming, tools, your account, anything on OpenVibe.', 'public', 'system', 'forum', 0),
- ('off-topic', 'Off-topic', 'Anything that is not about OpenVibe.', 'public', 'system', 'forum', 0);`);
- const place = db.prepare(`UPDATE spaces SET group_id = (SELECT id FROM space_groups WHERE slug = ?), position = ?, style = ?, votes = ?
- WHERE slug = ? AND created_by = 'system' AND group_id IS NULL`);
- for (const [group, position, style, votes, slug] of [
- ['openvibe', 1, 'forum', 0, 'general'], ['openvibe', 2, 'forum', 0, 'help'], ['openvibe', 3, 'feed', 1, 'feedback'], ['openvibe', 4, 'feed', 1, 'roadmap'],
- ['community', 1, 'feed', 1, 'showcase'], ['community', 2, 'forum', 0, 'off-topic'],
- ]) place.run(group, position, style, votes, slug);
- const seedCategory = db.prepare(`INSERT OR IGNORE INTO categories (space_id, slug, name, description, position)
- SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?`);
- for (const [space, slug, name, description, position] of [
- ['feedback', 'ideas', 'Ideas', 'Something new, or something better.', 1],
- ['feedback', 'bugs', 'Bugs', 'Something is broken or wrong.', 2],
- ['feedback', 'questions', 'Questions', 'How do I…? Why does…?', 3],
- ['roadmap', 'launches', 'New sites', 'Sites that open next.', 1],
- ['roadmap', 'features', 'Features', 'New things on sites that are already open.', 2],
- ['roadmap', 'platform', 'Under the hood', 'Accounts, safety, reliability and the shared systems every site uses.', 3],
- ]) seedCategory.run(slug, name, description, position, space);
- // The Discord relay both ways (WS-J tasks 5 and 6; server/relay): where a mapping's webhook posts and
- // whether replies come back, the Discord name on posts from Discord, and relay_deliveries rebuilt once
- // for replies, edits and deletes (the older table allowed one row per (thread, mapping) and no actions).
- const mappingCols = new Set(db.prepare('PRAGMA table_info(relay_mappings)').all().map((x) => x.name));
- if (!mappingCols.has('discord_channel_id')) db.exec('ALTER TABLE relay_mappings ADD COLUMN discord_channel_id TEXT');
- if (!mappingCols.has('discord_thread_id')) db.exec('ALTER TABLE relay_mappings ADD COLUMN discord_thread_id TEXT');
- if (!mappingCols.has('inbound')) db.exec('ALTER TABLE relay_mappings ADD COLUMN inbound INTEGER NOT NULL DEFAULT 0');
- const postCols = new Set(db.prepare('PRAGMA table_info(posts)').all().map((x) => x.name));
- if (!postCols.has('relay_author')) db.exec('ALTER TABLE posts ADD COLUMN relay_author TEXT');
- const deliveryCols = new Set(db.prepare('PRAGMA table_info(relay_deliveries)').all().map((x) => x.name));
- if (!deliveryCols.has('action')) {
- db.transaction(() => {
- db.exec(`CREATE TABLE relay_deliveries_v2 (${RELAY_DELIVERIES_COLUMNS});
- INSERT INTO relay_deliveries_v2 (id, thread_id, mapping_id, dedupe_key, status, attempts, next_attempt_at, last_status, last_error, delivered_at, created_at, updated_at)
- SELECT id, thread_id, mapping_id, dedupe_key, status, attempts, next_attempt_at, last_status, last_error, delivered_at, created_at, updated_at FROM relay_deliveries;
- DROP TABLE relay_deliveries;
- ALTER TABLE relay_deliveries_v2 RENAME TO relay_deliveries;
- CREATE INDEX IF NOT EXISTS idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);`);
- })();
- }
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_deliveries_thread ON relay_deliveries(thread_id, post_id);
- CREATE INDEX IF NOT EXISTS idx_relay_mappings_channel ON relay_mappings(discord_channel_id) WHERE discord_channel_id IS NOT NULL;`);
+ 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: 'community-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'community', registry, log });
}
}
/** cth_ + 22 base64url characters (16 random bytes). */
/** cth_ + 22 base64url characters (16 random bytes). */
function newThreadAccessId() { return `cth_${crypto.randomBytes(16).toString('base64url')}`; }
function newThreadAccessId() { return `cth_${crypto.randomBytes(16).toString('base64url')}`; }
let _shared = null;
let _shared = null;
-/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */
+/** Open the process-wide database once, at boot (server/index.js). */
+async function initDb(config, opts) {
+ if (!_shared) _shared = await openDb(config, opts);
+ return _shared;
+}
+/** The process-wide database initDb() opened. */
function getDb() {
function getDb() {
- if (!_shared) _shared = openDb(require('./config').dbPath);
+ if (!_shared) throw new Error('the database is not open: await initDb(config) at boot');
return _shared;
return _shared;
}
}
+/** Tests: use this handle as the process-wide database. */
+function setDb(db) { _shared = db; }
-/** Graceful stop: close the process-wide database (a later getDb() opens it again). */
-function closeDb() {
+/** Graceful stop: close the process-wide database. */
+async function closeDb() {
const db = _shared;
const db = _shared;
_shared = null;
_shared = null;
- if (db) try { db.close(); } catch { /* already closed */ }
+ if (db) await db.close().catch(() => {});
}
}
-module.exports = { openDb, getDb, closeDb, registerFunctions, SCHEMA, newThreadAccessId };
+module.exports = { openDb, initDb, getDb, setDb, closeDb, MIGRATIONS, newThreadAccessId };
@@ -16,10 +16,11 @@
@@ -16,10 +16,11 @@
*/
*/
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 config = require('./config');
const config = require('./config');
let outbox = null;
let outbox = null;
+let outboxDb = null; // the handle enqueue() writes through (it joins the caller's ambient transaction)
let pruneTimer = null;
let pruneTimer = null;
const stats = { queued: 0, lastError: null };
const stats = { queued: 0, lastError: null };
const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;
const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;
@@ -29,16 +30,17 @@ function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oa
@@ -29,16 +30,17 @@ function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oa
if (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;
if (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;
const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'community', clientSecret, fetch: fetchImpl });
const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'community', clientSecret, fetch: fetchImpl });
const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });
const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });
- outbox = createOutbox(db, {
+ outboxDb = db;
+ // The PostgreSQL outbox (its table is in migrations/0001_initial.sql); several processes relay it safely (leases).
+ outbox = createPgOutbox(db, {
events: createEventsClient(client, { source: 'community' }),
events: createEventsClient(client, { source: 'community' }),
intervalMs: intervalMs || 2000,
intervalMs: intervalMs || 2000,
onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },
onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },
});
});
- outbox.ensureSchema();
outbox.start();
outbox.start();
- pruneTimer = setInterval(() => { try { outbox.prune(); } catch { /* next time */ } }, PRUNE_EVERY_MS);
+ pruneTimer = setInterval(() => { outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);
if (pruneTimer.unref) pruneTimer.unref();
if (pruneTimer.unref) pruneTimer.unref();
- console.log(`[Events] community → ${eventsUrl} (${outbox.pending()} pending)`);
+ outbox.pending().then((n) => console.log(`[Events] community → ${eventsUrl} (${n} pending)`), () => {});
return outbox;
return outbox;
}
}
@@ -48,9 +50,9 @@ const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? {
@@ -48,9 +50,9 @@ const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? {
* Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,
* Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,
* so the change rolls back with it. A no-op (null) while publishing is off.
* so the change rolls back with it. A no-op (null) while publishing is off.
*/
*/
-function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {
+async function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {
if (!outbox) return null;
if (!outbox) return null;
- const env = outbox.enqueue({
+ const env = await outbox.enqueue(outboxDb, {
event_type: eventType,
event_type: eventType,
actor: actor || { type: 'service', id: 'community' },
actor: actor || { type: 'service', id: 'community' },
subject,
subject,
@@ -59,7 +61,7 @@ function record(eventType, subject, payload, { isPublic = false, actor = null }
@@ -59,7 +61,7 @@ function record(eventType, subject, payload, { isPublic = false, actor = null }
payload,
payload,
});
});
stats.queued++;
stats.queued++;
- setImmediate(() => outbox && outbox.kick());
+ outboxDb.afterCommit(() => outbox && outbox.kick());
return env;
return env;
}
}
@@ -67,34 +69,34 @@ function record(eventType, subject, payload, { isPublic = false, actor = null }
@@ -67,34 +69,34 @@ function record(eventType, subject, payload, { isPublic = false, actor = null }
const pasteUrl = (p) => (p.visibility === 'public' && !p.deleted_at ? `${config.baseUrl}/p/${encodeURIComponent(p.slug)}` : null);
const pasteUrl = (p) => (p.visibility === 'public' && !p.deleted_at ? `${config.baseUrl}/p/${encodeURIComponent(p.slug)}` : null);
const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };
const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };
-function pasteCreated(p) {
- return record('community.paste.created', { type: 'paste', id: p.slug }, {
+async function pasteCreated(p) {
+ return await record('community.paste.created', { type: 'paste', id: p.slug }, {
paste_id: p.slug, type: p.type === 'screenshot' ? 'screenshot' : 'paste', origin: ['user', 'ai', 'import'].includes(p.origin) ? p.origin : 'user',
paste_id: p.slug, type: p.type === 'screenshot' ? 'screenshot' : 'paste', origin: ['user', 'ai', 'import'].includes(p.origin) ? p.origin : 'user',
visibility: p.visibility || 'public', owner: subjectRef(p.owner_subject), url: pasteUrl(p),
visibility: p.visibility || 'public', owner: subjectRef(p.owner_subject), url: pasteUrl(p),
}, { isPublic: p.visibility === 'public', actor: actorOf(p.owner_subject) });
}, { isPublic: p.visibility === 'public', actor: actorOf(p.owner_subject) });
}
}
-function pasteUpdated(p, changed) {
+async function pasteUpdated(p, changed) {
const list = [...new Set(changed)].filter((c) => ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].includes(c));
const list = [...new Set(changed)].filter((c) => ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].includes(c));
if (!list.length) return null;
if (!list.length) return null;
- return record('community.paste.updated', { type: 'paste', id: p.slug, revision: Number(p.revision) || 1 }, {
+ return await record('community.paste.updated', { type: 'paste', id: p.slug, revision: Number(p.revision) || 1 }, {
paste_id: p.slug, changed: list, visibility: p.deleted_at ? 'deleted' : (p.visibility || 'public'), revision: Number(p.revision) || 1, url: pasteUrl(p),
paste_id: p.slug, changed: list, visibility: p.deleted_at ? 'deleted' : (p.visibility || 'public'), revision: Number(p.revision) || 1, url: pasteUrl(p),
}, { isPublic: p.visibility === 'public' });
}, { isPublic: p.visibility === 'public' });
}
}
-function pasteDeleted(slug) {
- return record('community.paste.deleted', { type: 'paste', id: slug }, { paste_id: slug }, { isPublic: true });
+async function pasteDeleted(slug) {
+ return await record('community.paste.deleted', { type: 'paste', id: slug }, { paste_id: slug }, { isPublic: true });
}
}
// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).
// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).
const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');
const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');
-function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
+async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
const visibility = forumVisibility(thread, spaceVisibility);
const visibility = forumVisibility(thread, spaceVisibility);
- return record('community.thread.created', { type: 'thread', id: String(thread.id) }, {
+ return await record('community.thread.created', { type: 'thread', id: String(thread.id) }, {
thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,
url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,
}, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
}, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
}
}
-function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
+async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
const visibility = forumVisibility(thread, spaceVisibility);
const visibility = forumVisibility(thread, spaceVisibility);
- return record('community.post.created', { type: 'post', id: String(post.id) }, {
+ return await record('community.post.created', { type: 'post', id: String(post.id) }, {
post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
@@ -104,10 +106,10 @@ function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
@@ -104,10 +106,10 @@ function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
* refIsPublic): the thread's access id and its ref (an unlisted paste's slug, a private VOD's id) are
* refIsPublic): the thread's access id and its ref (an unlisted paste's slug, a private VOD's id) are
* what keep a private item's comments private, and public events reach every realtime listener and app.
* what keep a private item's comments private, and public events reach every realtime listener and app.
*/
*/
-function commentCreated(comment, cthread, { itemPublic = false } = {}) {
+async function commentCreated(comment, cthread, { itemPublic = false } = {}) {
const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';
const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';
const isPublic = visibility !== 'hidden' && !!itemPublic;
const isPublic = visibility !== 'hidden' && !!itemPublic;
- return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {
+ return await record('community.comment.created', { type: 'comment', id: String(comment.id) }, {
comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },
comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },
author: subjectRef(comment.author_subject), visibility, url: isPublic ? `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}` : null,
author: subjectRef(comment.author_subject), visibility, url: isPublic ? `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}` : null,
}, { isPublic, actor: actorOf(comment.author_subject) });
}, { isPublic, actor: actorOf(comment.author_subject) });
@@ -117,17 +119,23 @@ function commentCreated(comment, cthread, { itemPublic = false } = {}) {
@@ -117,17 +119,23 @@ function commentCreated(comment, cthread, { itemPublic = false } = {}) {
* A staff action on someone else's content: community.moderation.action, for the network's
* A staff action on someone else's content: community.moderation.action, for the network's
* moderation audit log (ADR-022). Call inside the same transaction as the change. Never the content.
* moderation audit log (ADR-022). Call inside the same transaction as the change. Never the content.
*/
*/
-function moderationAction(v, action, target, { reason = null, details = {} } = {}) {
+async function moderationAction(v, action, target, { reason = null, details = {} } = {}) {
const actorSubject = v && v.subject ? v.subject : null;
const actorSubject = v && v.subject ? v.subject : null;
const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };
const t = { type: target.type, id: String(target.id).slice(0, 200), ...(target.owner_subject !== undefined ? { owner_subject: target.owner_subject || null } : {}) };
- return record('community.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
+ return await record('community.moderation.action', { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
{ action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
{ action, target: t, actor_subject: actorSubject, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
{ actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });
{ actor: actorOf(actorSubject) || (v && v.service ? { type: 'service', id: String(v.service) } : null) });
}
}
+/** Synchronous: whether the relay is on, and what this process queued. */
function status() {
function status() {
if (!outbox) return { enabled: false };
if (!outbox) return { enabled: false };
- return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };
+ return { enabled: true, queued_since_boot: stats.queued, last_error: stats.lastError };
+}
+/** status() with the outbox's backlog (rows not sent yet, rows Events refused). */
+async function backlog() {
+ if (!outbox) return { enabled: false };
+ return { ...status(), pending: await outbox.pending(), rejected: await outbox.rejected() };
}
}
/**
/**
* Graceful stop (server/index.js): no further sends; resolves when the send in progress has finished.
* Graceful stop (server/index.js): no further sends; resolves when the send in progress has finished.
@@ -138,6 +146,6 @@ function stop() {
@@ -138,6 +146,6 @@ function stop() {
pruneTimer = null;
pruneTimer = null;
return outbox ? outbox.stop() : Promise.resolve();
return outbox ? outbox.stop() : Promise.resolve();
}
}
-function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }
+function _reset() { if (outbox) outbox.stop(); outbox = null; outboxDb = null; stats.queued = 0; stats.lastError = null; }
-module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, stop, _reset };
+module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, backlog, stop, _reset };
@@ -60,35 +60,35 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -60,35 +60,35 @@ function createSpacesApi({ forum, viewers, limits }) {
const configure = limits('community.space.configure', { minute: 30, hour: 300 }); // moderators' settings
const configure = limits('community.space.configure', { minute: 30, hour: 300 }); // moderators' settings
const editThread = limits('community.thread.edit', { minute: 30, hour: 300 });
const editThread = limits('community.thread.edit', { minute: 30, hour: 300 });
- router.get('/', run((req) => forum.listSpaces(req.viewer)));
- router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.createSpace(req.viewer, req.body || {}), 201));
- router.put('/:space/settings', serviceCap(MOD), configure, jsonBody, run((req) => forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
- router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
- router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
- router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));
+ router.get('/', run(async (req) => await forum.listSpaces(req.viewer)));
+ router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createSpace(req.viewer, req.body || {}), 201));
+ router.put('/:space/settings', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
+ router.get('/:space', run(async (req) => await forum.space(req.viewer, p(req).space)));
+ router.get('/:space/threads', run(async (req) => await forum.listThreads(req.viewer, p(req).space, req.query)));
+ router.get('/:space/categories', run(async (req) => await forum.categories(req.viewer, p(req).space)));
// An image to attach: multipart `file`; then name its media_id in `attachments` when posting.
// An image to attach: multipart `file`; then name its media_id in `attachments` when posting.
const one = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 5 } }).single('file');
const one = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 5 } }).single('file');
const withFile = (req, res, next) => one(req, res, (err) => (err
const withFile = (req, res, next) => one(req, res, (err) => (err
? contracts.http.sendProblem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.code === 'LIMIT_FILE_SIZE' ? 'attachments.too_large' : 'attachments.invalid', { detail: err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : 'Send one image as multipart field `file`', ctx: req.ov })
? contracts.http.sendProblem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.code === 'LIMIT_FILE_SIZE' ? 'attachments.too_large' : 'attachments.invalid', { detail: err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : 'Send one image as multipart field `file`', ctx: req.ov })
: next()));
: next()));
// An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.
// An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.
- router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run((req) => forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
- router.put('/:space/categories/:category', serviceCap(MOD), configure, jsonBody, run((req) => forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
- router.delete('/:space/categories/:category', serviceCap(MOD), configure, run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
- router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/status', serviceCap(MOD), configure, jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
- router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
- router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
- router.delete('/:space/threads/:slug', writeOrMod, editThread, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
- router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
- router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/state', serviceCap(MOD), configure, jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
- router.put('/:space/members-only', serviceCap(MOD), configure, jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
+ router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run(async (req) => await forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
+ router.put('/:space/categories/:category', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
+ router.delete('/:space/categories/:category', serviceCap(MOD), configure, run(async (req) => await forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
+ router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/threads/:slug/status', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
+ router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run(async (req) => await forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
+ router.get('/:space/threads/:slug', run(async (req) => await forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
+ router.delete('/:space/threads/:slug', writeOrMod, editThread, run(async (req) => await forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
+ router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run(async (req) => await forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
+ router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/threads/:slug/state', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run(async (req) => await forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/members-only', serviceCap(MOD), configure, jsonBody, run(async (req) => await forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
// A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.
// A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.
- router.put('/:space/chat-room', configure, jsonBody, run((req) => forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
- router.delete('/:space/chat-room', serviceCap(MOD), configure, run((req) => forum.detachChatRoom(req.viewer, p(req).space)));
+ router.put('/:space/chat-room', configure, jsonBody, run(async (req) => await forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
+ router.delete('/:space/chat-room', serviceCap(MOD), configure, run(async (req) => await forum.detachChatRoom(req.viewer, p(req).space)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
@@ -102,10 +102,10 @@ function createPostsApi({ forum, viewers, limits }) {
@@ -102,10 +102,10 @@ function createPostsApi({ forum, viewers, limits }) {
// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
router.use(limits.reads('community.forum.read'));
router.use(limits.reads('community.forum.read'));
- router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));
- router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run((req) => forum.react(req.viewer, req.params.id, req.body || {})));
- router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), run((req) => forum.deletePost(req.viewer, req.params.id)));
- router.get('/:id/versions', writeOrMod, run((req) => forum.postVersions(req.viewer, req.params.id)));
+ router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));
+ router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));
+ router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), run(async (req) => await forum.deletePost(req.viewer, req.params.id)));
+ router.get('/:id/versions', writeOrMod, run(async (req) => await forum.postVersions(req.viewer, req.params.id)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
@@ -119,7 +119,7 @@ function createGroupsApi({ forum, viewers, limits }) {
@@ -119,7 +119,7 @@ function createGroupsApi({ forum, viewers, limits }) {
// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
router.use(limits.reads('community.forum.read'));
router.use(limits.reads('community.forum.read'));
router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));
router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));
- router.put('/:group', serviceCap(MOD), limits('community.space.configure', { minute: 30, hour: 300 }), jsonBody, run((req) => forum.putGroup(req.viewer, req.params.group, req.body || {})));
+ router.put('/:group', serviceCap(MOD), limits('community.space.configure', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.putGroup(req.viewer, req.params.group, req.body || {})));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
}
}
@@ -21,14 +21,14 @@ const BY_KEY = new Map(REACTIONS.map((r) => [r.key, r]));
@@ -21,14 +21,14 @@ const BY_KEY = new Map(REACTIONS.map((r) => [r.key, r]));
const ORDER = new Map(REACTIONS.map((r, i) => [r.key, i]));
const ORDER = new Map(REACTIONS.map((r, i) => [r.key, i]));
/** Set (or with null, remove) a person's rating of a post. → the rating now held, or null */
/** Set (or with null, remove) a person's rating of a post. → the rating now held, or null */
-function setReaction(db, postId, subject, reaction) {
- const cur = db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);
+async function setReaction(db, postId, subject, reaction) {
+ const cur = await db.prepare('SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?').get(postId, subject);
if (!reaction || (cur && cur.reaction === reaction)) {
if (!reaction || (cur && cur.reaction === reaction)) {
- db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);
+ await db.prepare('DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?').run(postId, subject);
return null;
return null;
}
}
- db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)
- ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = CURRENT_TIMESTAMP`).run(postId, subject, reaction);
+ await db.prepare(`INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?)
+ ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = ov_now()`).run(postId, subject, reaction);
return reaction;
return reaction;
}
}
@@ -38,10 +38,10 @@ const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');
@@ -38,10 +38,10 @@ const placeholders = (n) => Array.from({ length: n }, () => '?').join(', ');
* Ratings of these posts. → Map postId → [{ key, emoji, label, group, count, raters: [subject…] (first 8), mine }]
* Ratings of these posts. → Map postId → [{ key, emoji, label, group, count, raters: [subject…] (first 8), mine }]
* in the fixed order, only ratings someone gave.
* in the fixed order, only ratings someone gave.
*/
*/
-function reactionsFor(db, postIds, viewerSubject = null) {
+async function reactionsFor(db, postIds, viewerSubject = null) {
const out = new Map();
const out = new Map();
if (!postIds.length) return out;
if (!postIds.length) return out;
- const rows = db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);
+ const rows = await db.prepare(`SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (${placeholders(postIds.length)}) ORDER BY created_at, subject_id`).all(...postIds);
for (const r of rows) {
for (const r of rows) {
const def = BY_KEY.get(r.reaction);
const def = BY_KEY.get(r.reaction);
if (!def) continue;
if (!def) continue;
@@ -57,11 +57,11 @@ function reactionsFor(db, postIds, viewerSubject = null) {
@@ -57,11 +57,11 @@ function reactionsFor(db, postIds, viewerSubject = null) {
}
}
/** The ratings each author has received, most given first (top 3). → Map subject → [{ key, emoji, label, count }] */
/** The ratings each author has received, most given first (top 3). → Map subject → [{ key, emoji, label, count }] */
-function receivedBy(db, subjects) {
+async function receivedBy(db, subjects) {
const out = new Map();
const out = new Map();
const list = [...new Set(subjects.filter(Boolean))];
const list = [...new Set(subjects.filter(Boolean))];
if (!list.length) return out;
if (!list.length) return out;
- const rows = db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id
+ const rows = await db.prepare(`SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id
WHERE p.author_subject IN (${placeholders(list.length)}) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction`).all(...list);
WHERE p.author_subject IN (${placeholders(list.length)}) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction`).all(...list);
for (const r of rows) {
for (const r of rows) {
const def = BY_KEY.get(r.reaction);
const def = BY_KEY.get(r.reaction);
@@ -16,9 +16,9 @@ const FILE = path.join(__dirname, '..', '..', 'docs', 'roadmap', 'public.json');
@@ -16,9 +16,9 @@ const FILE = path.join(__dirname, '..', '..', 'docs', 'roadmap', 'public.json');
const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;
const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;
/** items: [{ key, title, status, category?, summary }] → { created, updated, unchanged, skipped } */
/** items: [{ key, title, status, category?, summary }] → { created, updated, unchanged, skipped } */
-function syncRoadmap(db, items, { log = console } = {}) {
+async function syncRoadmap(db, items, { log = console } = {}) {
const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };
const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };
- const space = store.getSpace(db, 'roadmap');
+ const space = await store.getSpace(db, 'roadmap');
if (!space) return out;
if (!space) return out;
for (const item of Array.isArray(items) ? items : []) {
for (const item of Array.isArray(items) ? items : []) {
const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();
const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();
@@ -28,31 +28,31 @@ function syncRoadmap(db, items, { log = console } = {}) {
@@ -28,31 +28,31 @@ function syncRoadmap(db, items, { log = console } = {}) {
log.warn && log.warn(`[Roadmap] skipped an item that is not { key, title, status, summary }: ${JSON.stringify(item && item.key)}`);
log.warn && log.warn(`[Roadmap] skipped an item that is not { key, title, status, summary }: ${JSON.stringify(item && item.key)}`);
continue;
continue;
}
}
- const category = item.category ? store.getCategory(db, space.id, item.category) : null;
+ const category = item.category ? await store.getCategory(db, space.id, item.category) : null;
const categoryId = category ? category.id : null;
const categoryId = category ? category.id : null;
- const existing = store.getThreadByKey(db, space.id, item.key);
+ const existing = await store.getThreadByKey(db, space.id, item.key);
if (!existing) {
if (!existing) {
- store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });
+ await store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });
out.created++;
out.created++;
continue;
continue;
}
}
let changed = false;
let changed = false;
if (existing.title !== title || existing.status !== item.status || (existing.category_id || null) !== categoryId) {
if (existing.title !== title || existing.status !== item.status || (existing.category_id || null) !== categoryId) {
- db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);
+ await db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);
changed = true;
changed = true;
}
}
- const opening = db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);
- if (opening && opening.body_markdown !== summary) { store.editPost(db, opening.id, summary, 'system'); changed = true; }
+ const opening = await db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);
+ if (opening && opening.body_markdown !== summary) { await store.editPost(db, opening.id, summary, 'system'); changed = true; }
if (changed) out.updated++; else out.unchanged++;
if (changed) out.updated++; else out.unchanged++;
}
}
return out;
return out;
}
}
/** Sync from docs/roadmap/public.json (or `file`). A missing or broken file changes nothing. */
/** Sync from docs/roadmap/public.json (or `file`). A missing or broken file changes nothing. */
-function syncFromFile(db, { file = FILE, log = console } = {}) {
+async function syncFromFile(db, { file = FILE, log = console } = {}) {
let doc;
let doc;
try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Roadmap] ${file}: ${err.message}`); return null; }
try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Roadmap] ${file}: ${err.message}`); return null; }
- return syncRoadmap(db, doc.items, { log });
+ return await syncRoadmap(db, doc.items, { log });
}
}
module.exports = { syncRoadmap, syncFromFile, FILE };
module.exports = { syncRoadmap, syncFromFile, FILE };
@@ -78,7 +78,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -78,7 +78,7 @@ function createForumRoutes({ forum, viewers, config }) {
try {
try {
if (!req.user) return login(res, '/s/new-space');
if (!req.user) return login(res, '/s/new-space');
if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create spaces');
if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create spaces');
- html(res, boardPages.newSpacePage({ groups: forum.groups() }));
+ html(res, boardPages.newSpacePage({ groups: await forum.groups() }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
@@ -90,7 +90,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -90,7 +90,7 @@ function createForumRoutes({ forum, viewers, config }) {
res.redirect(303, `/s/${out.space.slug}`);
res.redirect(303, `/s/${out.space.slug}`);
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
- html(res, boardPages.newSpacePage({ groups: forum.groups(), error: err.message, values }), err.status);
+ html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);
}
}
}));
}));
@@ -103,14 +103,14 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -103,14 +103,14 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.get('/s/feed.xml', (_req, res) => {
- res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ threads: forum.recentPublic({ limit: 30 }) }));
+ router.get('/s/feed.xml', async (_req, res) => {
+ res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ threads: await forum.recentPublic({ limit: 30 }) }));
});
});
- router.get('/s/:space/feed.xml', (req, res) => {
- const space = forum.publicSpaces().find((s) => s.slug === req.params.space);
+ router.get('/s/:space/feed.xml', async (req, res) => {
+ const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);
if (!space) return res.status(404).type('text/plain').send('Not found');
if (!space) return res.status(404).type('text/plain').send('Not found');
- res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ space, threads: forum.recentPublic({ limit: 30, space: space.slug }) }));
+ res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ space, threads: await forum.recentPublic({ limit: 30, space: space.slug }) }));
});
});
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
@@ -130,7 +130,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -130,7 +130,7 @@ function createForumRoutes({ forum, viewers, config }) {
if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
const { space } = await forum.space(req.viewer, req.params.space);
const { space } = await forum.space(req.viewer, req.params.space);
const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';
const paste = /^[A-Za-z0-9_-]{3,80}$/.test(String(req.query.paste || '')) ? String(req.query.paste) : '';
- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: forum.pasteTitle(paste) || '' } : {} }));
+ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled(), values: paste ? { pastes: paste, title: await forum.pasteTitle(paste) || '' } : {} }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -149,7 +149,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -149,7 +149,7 @@ function createForumRoutes({ forum, viewers, config }) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
try {
try {
const { space } = await forum.space(req.viewer, req.params.space);
const { space } = await forum.space(req.viewer, req.params.space);
- const { categories } = forum.categories(req.viewer, req.params.space);
+ const { categories } = await forum.categories(req.viewer, req.params.space);
html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories, attachments: forum.attachmentsEnabled() }), err.status);
html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories, attachments: forum.attachmentsEnabled() }), err.status);
} catch (e) { failPage(req, res, e, next); }
} catch (e) { failPage(req, res, e, next); }
}
}
@@ -158,7 +158,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -158,7 +158,7 @@ function createForumRoutes({ forum, viewers, config }) {
async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });
const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
- if (status === 200 && forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;
+ if (status === 200 && await forum.recordView(out.thread.id, req.user && req.user.subject_id ? `s:${req.user.subject_id}` : `ip:${req.ip}`)) out.thread.views += 1;
// Quote (no JS): ?quote=<post id> fills the reply box with the quoted post.
// Quote (no JS): ?quote=<post id> fills the reply box with the quoted post.
if (!draft && /^\d{1,15}$/.test(String(req.query.quote || '')) && out.viewer.can_reply) {
if (!draft && /^\d{1,15}$/.test(String(req.query.quote || '')) && out.viewer.can_reply) {
try { draft = (await forum.quote(req.viewer, req.query.quote)).markdown; } catch { /* a post that is gone: an empty box */ }
try { draft = (await forum.quote(req.viewer, req.query.quote)).markdown; } catch { /* a post that is gone: an empty box */ }
@@ -280,9 +280,9 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -280,9 +280,9 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, (req, res, next) => {
+ router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
try {
try {
- forum.deleteThread(req.viewer, req.params.space, req.params.slug);
+ await forum.deleteThread(req.viewer, req.params.space, req.params.slug);
seo.resetCaches();
seo.resetCaches();
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
@@ -107,13 +107,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -107,13 +107,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// Views: one per viewer (person, or address) per thread per 30 minutes; memory only.
// Views: one per viewer (person, or address) per thread per 30 minutes; memory only.
const viewSeen = new Map();
const viewSeen = new Map();
const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
- const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
+ const hook = async (fn) => { try { await fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
const moderator = (v) => discussionModerator(v);
const moderator = (v) => discussionModerator(v);
const person = (v) => !!(v && v.subject);
const person = (v) => !!(v && v.subject);
// ── a space's chat room (OpenVibe.Chat) ──────────────────
// ── a space's chat room (OpenVibe.Chat) ──────────────────
- const chatRoomRow = (spaceId) => (spaceId == null ? null : db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
+ const chatRoomRow = async (spaceId) => (spaceId == null ? null : await db.prepare('SELECT * FROM space_chat_rooms WHERE space_id = ?').get(spaceId) || null);
const shapeChatRoom = (r) => (r ? {
const shapeChatRoom = (r) => (r ? {
slug: r.room_slug, name: r.room_name, kind: r.room_kind, visibility: r.room_visibility,
slug: r.room_slug, name: r.room_name, kind: r.room_kind, visibility: r.room_visibility,
url: chatRooms ? chatRooms.roomUrl(r.room_slug) : `https://openvibe.chat/r/${encodeURIComponent(r.room_slug)}`,
url: chatRooms ? chatRooms.roomUrl(r.room_slug) : `https://openvibe.chat/r/${encodeURIComponent(r.room_slug)}`,
@@ -173,27 +173,27 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -173,27 +173,27 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
}
/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
- function spaceFor(v, slug) {
- const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? store.getSpace(db, slug) : null;
+ async function spaceFor(v, slug) {
+ const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? await store.getSpace(db, slug) : null;
if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');
if (!space || (space.visibility === 'staff' && !moderator(v))) fail(404, 'space.not_found', 'No such space');
if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');
if (!canRead(v, space)) fail(401, 'auth.required', 'Sign in to read this space');
return space;
return space;
}
}
- function threadFor(v, spaceSlug, threadSlug) {
- const space = spaceFor(v, spaceSlug);
- const thread = store.getThreadBySlug(db, space.id, threadSlug);
+ async function threadFor(v, spaceSlug, threadSlug) {
+ const space = await spaceFor(v, spaceSlug);
+ const thread = await store.getThreadBySlug(db, space.id, threadSlug);
if (!thread) fail(404, 'thread.not_found', 'No such thread');
if (!thread) fail(404, 'thread.not_found', 'No such thread');
return { space, thread };
return { space, thread };
}
}
- function postFor(v, postId) {
- const post = /^\d{1,15}$/.test(String(postId)) ? store.getPost(db, Number(postId)) : null;
- const thread = post ? store.getThread(db, post.thread_id) : null;
- const space = thread ? store.getSpaceById(db, thread.space_id) : null;
+ async function postFor(v, postId) {
+ const post = /^\d{1,15}$/.test(String(postId)) ? await store.getPost(db, Number(postId)) : null;
+ const thread = post ? await store.getThread(db, post.thread_id) : null;
+ const space = thread ? await store.getSpaceById(db, thread.space_id) : null;
if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');
if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');
// A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).
// A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).
- try { spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }
+ try { await spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }
return { post, thread, space };
return { post, thread, space };
}
}
@@ -221,13 +221,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -221,13 +221,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);
const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);
- function shapeThread(t, space, v, projections, votes) {
+ async function shapeThread(t, space, v, projections, votes) {
const mo = t.members_only_owner || null;
const mo = t.members_only_owner || null;
const p = mo && projections ? projections.get(mo) : null;
const p = mo && projections ? projections.get(mo) : null;
return {
return {
id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
kind: t.kind || 'discussion', status: t.status || null,
kind: t.kind || 'discussion', status: t.status || null,
- category: t.category_id ? shapeCategory(store.getCategoryById(db, t.category_id)) : null,
+ category: t.category_id ? shapeCategory(await store.getCategoryById(db, t.category_id)) : null,
members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count, views: t.views || 0,
pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count, views: t.views || 0,
@@ -241,43 +241,43 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -241,43 +241,43 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, mime: a.mime, size_bytes: a.size_bytes });
const shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, mime: a.mime, size_bytes: a.size_bytes });
/** post id → [attachment], in order. */
/** post id → [attachment], in order. */
- function attachmentsOf(postIds) {
+ async function attachmentsOf(postIds) {
const out = new Map();
const out = new Map();
if (!postIds.length) return out;
if (!postIds.length) return out;
- const rows = db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);
+ const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);
for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }
for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }
return out;
return out;
}
}
/** The attachments a write names: the writer's own uploads, not yet on a post, at most ATTACH_MAX. → rows */
/** The attachments a write names: the writer's own uploads, not yet on a post, at most ATTACH_MAX. → rows */
- function claimable(w, ids) {
+ async function claimable(w, ids) {
if (ids === undefined || ids === null) return [];
if (ids === undefined || ids === null) return [];
const list = Array.isArray(ids) ? ids.map(String) : [String(ids)];
const list = Array.isArray(ids) ? ids.map(String) : [String(ids)];
if (list.length > ATTACH_MAX) fail(400, 'attachments.too_many', `At most ${ATTACH_MAX} images per post`);
if (list.length > ATTACH_MAX) fail(400, 'attachments.too_many', `At most ${ATTACH_MAX} images per post`);
if (!w.author && list.length) fail(403, 'attachments.person_only', 'Only people attach images');
if (!w.author && list.length) fail(403, 'attachments.person_only', 'Only people attach images');
- const rows = list.map((id) => (MED_ID.test(id) ? db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(id) : null));
+ const rows = (await Promise.all(list.map(async (id) => (MED_ID.test(id) ? await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(id) : null))));
if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');
if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');
return rows;
return rows;
}
}
- function claimPastes(refs) {
+ async function claimPastes(refs) {
const slugs = parsePasteRefs(refs);
const slugs = parsePasteRefs(refs);
if (slugs.length > PASTE_MAX) fail(400, 'pastes.too_many', `At most ${PASTE_MAX} pastes per post`);
if (slugs.length > PASTE_MAX) fail(400, 'pastes.too_many', `At most ${PASTE_MAX} pastes per post`);
- return slugs.map((slug) => {
- const r = db.prepare('SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?').get(slug);
+ return (await Promise.all(slugs.map(async (slug) => {
+ const r = await db.prepare('SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?').get(slug);
if (!r || r.deleted_at) fail(404, 'pastes.not_found', `No paste ${slug}`);
if (!r || r.deleted_at) fail(404, 'pastes.not_found', `No paste ${slug}`);
if (r.visibility === 'private' || r.burn_after_read) fail(400, 'pastes.not_shareable', `Paste ${slug} is private or burns after reading: it cannot be attached`);
if (r.visibility === 'private' || r.burn_after_read) fail(400, 'pastes.not_shareable', `Paste ${slug} is private or burns after reading: it cannot be attached`);
return r;
return r;
- });
+ })));
}
}
- function attachPastes(rows, postId) {
- const ins = db.prepare('INSERT OR IGNORE INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?)');
- rows.forEach((r, i) => ins.run(postId, r.id, i));
+ async function attachPastes(rows, postId) {
+ const ins = db.prepare('INSERT INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?) ON CONFLICT DO NOTHING');
+ for (const [i, r] of rows.entries()) await ins.run(postId, r.id, i);
}
}
/** post id → [paste card], live shareable pastes only (a paste made private or deleted drops out). */
/** post id → [paste card], live shareable pastes only (a paste made private or deleted drops out). */
- function pastesOf(postIds) {
+ async function pastesOf(postIds) {
const out = new Map();
const out = new Map();
if (!postIds.length) return out;
if (!postIds.length) return out;
- const rows = db.prepare(`SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.content
+ const rows = await db.prepare(`SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.content
FROM post_pastes pp JOIN pastes p ON p.id = pp.paste_id
FROM post_pastes pp JOIN pastes p ON p.id = pp.paste_id
WHERE pp.post_id IN (${postIds.map(() => '?').join(', ')}) AND p.deleted_at IS NULL AND p.visibility != 'private' AND p.burn_after_read = 0
WHERE pp.post_id IN (${postIds.map(() => '?').join(', ')}) AND p.deleted_at IS NULL AND p.visibility != 'private' AND p.burn_after_read = 0
ORDER BY pp.post_id, pp.position`).all(...postIds);
ORDER BY pp.post_id, pp.position`).all(...postIds);
@@ -290,9 +290,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -290,9 +290,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return out;
return out;
}
}
- function attach(rows, postId) {
+ async function attach(rows, postId) {
const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
- rows.forEach((r, i) => set.run(postId, i, r.media_id));
+ for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);
}
}
function shapePost(p, v, projections, attachments = null, pastes = null) {
function shapePost(p, v, projections, attachments = null, pastes = null) {
@@ -313,11 +313,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -313,11 +313,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async function shapeThreads(rows, spaceOf, v) {
async function shapeThreads(rows, spaceOf, v) {
const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner), ...rows.map((r) => r.last_author_subject)]);
const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner), ...rows.map((r) => r.last_author_subject)]);
- const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
- return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));
+ const votes = await myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
+ return (await Promise.all(rows.map(async (r) => await shapeThread(r, spaceOf(r), v, projections, votes))));
}
}
- function shapeSpace(s, mo = null) {
+ async function shapeSpace(s, mo = null) {
return {
return {
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
@@ -328,7 +328,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -328,7 +328,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
thread_count: s.thread_count != null ? s.thread_count : undefined,
thread_count: s.thread_count != null ? s.thread_count : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
- chat_room: shapeChatRoom(chatRoomRow(s.id)),
+ chat_room: shapeChatRoom(await chatRoomRow(s.id)),
};
};
}
}
@@ -336,21 +336,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -336,21 +336,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* Newly gated threads leave Pulse at once, and Discord: what the relay had not sent yet is skipped and
* Newly gated threads leave Pulse at once, and Discord: what the relay had not sent yet is skipped and
* what it had sent is deleted there (reads and sends re-check as well).
* what it had sent is deleted there (reads and sends re-check as well).
*/
*/
- function hideGated(threadIds) {
+ async function hideGated(threadIds) {
if (!threadIds.length) return;
if (!threadIds.length) return;
- if (pulse) hook(() => { for (const id of threadIds) { pulse.threadGone(id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(id)) pulse.postGone(p.id); } });
- if (relay) hook(() => relay.hideThreads(threadIds, 'made members-only'));
+ if (pulse) await hook(async () => { for (const id of threadIds) { await pulse.threadGone(id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(id)) await pulse.postGone(p.id); } });
+ if (relay) await hook(async () => await relay.hideThreads(threadIds, 'made members-only'));
}
}
- function removeThread(v, thread) {
+ async function removeThread(v, thread) {
const mine = person(v) && thread.author_subject === v.subject;
const mine = person(v) && thread.author_subject === v.subject;
if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
- db.transaction(() => {
- store.softDeleteThread(db, thread.id);
- if (!mine) events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
- })();
- if (pulse) hook(() => { pulse.threadGone(thread.id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) pulse.postGone(p.id); });
- if (relay) hook(() => relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
+ await db.tx(async () => {
+ await store.softDeleteThread(db, thread.id);
+ if (!mine) await events.moderationAction(v, 'thread.deleted', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ });
+ if (pulse) await hook(async () => { await pulse.threadGone(thread.id); for (const p of await db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(thread.id)) await pulse.postGone(p.id); });
+ if (relay) await hook(async () => await relay.enqueueDelete(thread.id, null, mine ? 'deleted by its author' : 'deleted by a moderator'));
return { ok: true, id: thread.id, deleted: 'thread' };
return { ok: true, id: thread.id, deleted: 'thread' };
}
}
@@ -358,33 +358,33 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -358,33 +358,33 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* Where a thread came from and where else it was crossposted, as far as the viewer may see.
* Where a thread came from and where else it was crossposted, as far as the viewer may see.
* → { from: { space, title, url } | null, to: [{ space, title, url }], targets: [{ slug, name }] (spaces the viewer may crosspost to) }
* → { from: { space, title, url } | null, to: [{ space, title, url }], targets: [{ slug, name }] (spaces the viewer may crosspost to) }
*/
*/
- function crosspostInfo(v, thread) {
+ async function crosspostInfo(v, thread) {
const visible = (sp) => sp && !sp.members_only_owner && canRead(v, sp) && (sp.visibility !== 'staff' || moderator(v));
const visible = (sp) => sp && !sp.members_only_owner && canRead(v, sp) && (sp.visibility !== 'staff' || moderator(v));
let from = null;
let from = null;
if (thread.crosspost_of) {
if (thread.crosspost_of) {
- const t = store.getThread(db, thread.crosspost_of);
- const sp = t ? store.getSpaceById(db, t.space_id) : null;
+ const t = await store.getThread(db, thread.crosspost_of);
+ const sp = t ? await store.getSpaceById(db, t.space_id) : null;
if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };
if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };
}
}
- const to = db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id)
- .map((r) => ({ r, sp: store.getSpace(db, r.space_slug) })).filter(({ sp }) => visible(sp))
+ const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id))
+ .map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp))
.map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));
.map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));
const targets = person(v) && !thread.members_only_owner
const targets = person(v) && !thread.members_only_owner
- ? store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members']).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))
+ ? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))
.map((sp) => ({ slug: sp.slug, name: sp.name }))
.map((sp) => ({ slug: sp.slug, name: sp.name }))
: [];
: [];
return { from, to, targets };
return { from, to, targets };
}
}
/** Where a space sits on the board index (breadcrumbs). → { group, parent } */
/** Where a space sits on the board index (breadcrumbs). → { group, parent } */
- function placeOf(space) {
- const g = space.group_id ? db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;
- const p = space.parent_id ? db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;
+ async function placeOf(space) {
+ const g = space.group_id ? await db.prepare('SELECT slug, name FROM space_groups WHERE id = ?').get(space.group_id) : null;
+ const p = space.parent_id ? await db.prepare('SELECT slug, name FROM spaces WHERE id = ?').get(space.parent_id) : null;
return { group: g || null, parent: p || null };
return { group: g || null, parent: p || null };
}
}
/** Validated space fields from a settings body (only what was sent). */
/** Validated space fields from a settings body (only what was sent). */
- function spaceFields(body, space) {
+ async function spaceFields(body, space) {
const f = {};
const f = {};
if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Space names are 2 to 60 characters'); f.name = n; }
if (body.name !== undefined) { const n = cleanTitle(body.name); if (n.length < 2 || n.length > 60) fail(400, 'space.invalid_name', 'Space names are 2 to 60 characters'); f.name = n; }
if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;
if (body.description !== undefined) f.description = body.description == null ? null : cleanTitle(body.description).slice(0, 300) || null;
@@ -393,12 +393,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -393,12 +393,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
if (body.position !== undefined) f.position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }
if (body.kind !== undefined) { if (!['discussion', 'request', 'roadmap'].includes(body.kind)) fail(400, 'space.invalid_kind', 'kind is discussion, request or roadmap'); f.thread_kind = body.kind; }
if (body.group !== undefined) {
if (body.group !== undefined) {
- const g = body.group ? db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;
+ const g = body.group ? await db.prepare('SELECT id FROM space_groups WHERE slug = ?').get(String(body.group)) : null;
if (body.group && !g) fail(404, 'group.not_found', 'No such group on the board index');
if (body.group && !g) fail(404, 'group.not_found', 'No such group on the board index');
f.group_id = g ? g.id : null;
f.group_id = g ? g.id : null;
}
}
if (body.parent !== undefined) {
if (body.parent !== undefined) {
- const p = body.parent ? store.getSpace(db, String(body.parent)) : null;
+ const p = body.parent ? await store.getSpace(db, String(body.parent)) : null;
if (body.parent && !p) fail(404, 'space.not_found', 'No such parent space');
if (body.parent && !p) fail(404, 'space.not_found', 'No such parent space');
if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A space cannot sit under itself or its own child');
if (p && space && (p.id === space.id || p.parent_id === space.id)) fail(400, 'space.invalid_parent', 'A space cannot sit under itself or its own child');
if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');
if (p && p.parent_id) fail(400, 'space.invalid_parent', 'Child boards go one level deep');
@@ -415,11 +415,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -415,11 +415,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
isModerator: moderator,
isModerator: moderator,
/** The board index's groups (for the settings and new-space forms). */
/** The board index's groups (for the settings and new-space forms). */
- groups: () => store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })),
+ groups: async () => (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })),
/** A shareable paste's title (the new-topic form's "Discuss in a space"), or null. */
/** A shareable paste's title (the new-topic form's "Discuss in a space"), or null. */
- pasteTitle(slug) {
- const r = db.prepare("SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0").get(String(slug));
+ async pasteTitle(slug) {
+ const r = await db.prepare("SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0").get(String(slug));
return r ? r.title : null;
return r ? r.title : null;
},
},
@@ -431,11 +431,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -431,11 +431,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const vis = ['public'];
const vis = ['public'];
if (person(v) || moderator(v)) vis.push('members');
if (person(v) || moderator(v)) vis.push('members');
if (moderator(v)) vis.push('staff');
if (moderator(v)) vis.push('staff');
- const rows = store.listSpaces(db, vis);
- const last = store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));
+ const rows = await store.listSpaces(db, vis);
+ const last = await store.lastPosts(db, rows.filter((r) => !r.members_only_owner).map((r) => r.id));
const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);
const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);
const shaped = await Promise.all(rows.map(async (r) => {
const shaped = await Promise.all(rows.map(async (r) => {
- const sp = shapeSpace(r, await membersOnly(r.members_only_owner, projections));
+ const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));
const l = last.get(r.id);
const l = last.get(r.id);
sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
return sp;
return sp;
@@ -444,7 +444,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -444,7 +444,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const bySlug = new Map(shaped.map((sp) => [sp.slug, { ...sp, children: [] }]));
const bySlug = new Map(shaped.map((sp) => [sp.slug, { ...sp, children: [] }]));
for (const sp of bySlug.values()) if (sp.parent && bySlug.has(sp.parent.slug)) bySlug.get(sp.parent.slug).children.push(sp);
for (const sp of bySlug.values()) if (sp.parent && bySlug.has(sp.parent.slug)) bySlug.get(sp.parent.slug).children.push(sp);
const top = [...bySlug.values()].filter((sp) => !(sp.parent && bySlug.has(sp.parent.slug)));
const top = [...bySlug.values()].filter((sp) => !(sp.parent && bySlug.has(sp.parent.slug)));
- const groups = store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) }))
+ const groups = (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name, description: g.description || null, spaces: top.filter((sp) => sp.group && sp.group.slug === g.slug) }))
.filter((g) => g.spaces.length);
.filter((g) => g.spaces.length);
const other = top.filter((sp) => !sp.group);
const other = top.filter((sp) => !sp.group);
if (other.length) groups.push({ slug: null, name: 'More spaces', description: null, spaces: other });
if (other.length) groups.push({ slug: null, name: 'More spaces', description: null, spaces: other });
@@ -452,15 +452,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -452,15 +452,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
},
},
/** The board index's groups — moderators create or change one { name, description?, position? }. */
/** The board index's groups — moderators create or change one { name, description?, position? }. */
- putGroup(v, slug, body = {}) {
+ async putGroup(v, slug, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage the board index');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage the board index');
if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'group.invalid_slug', 'A group slug is 1 to 40 lowercase letters, digits and dashes');
if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'group.invalid_slug', 'A group slug is 1 to 40 lowercase letters, digits and dashes');
const name = cleanTitle(body.name);
const name = cleanTitle(body.name);
if (name.length < 2 || name.length > 60) fail(400, 'group.invalid_name', 'Group names are 2 to 60 characters');
if (name.length < 2 || name.length > 60) fail(400, 'group.invalid_name', 'Group names are 2 to 60 characters');
- db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)
+ await db.prepare(`INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?)
ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
.run(slug, name, body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null, Number.isInteger(Number(body.position)) ? Number(body.position) : 0);
.run(slug, name, body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null, Number.isInteger(Number(body.position)) ? Number(body.position) : 0);
- return { group: db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };
+ return { group: await db.prepare('SELECT slug, name, description, position FROM space_groups WHERE slug = ?').get(slug) };
},
},
/**
/**
@@ -469,11 +469,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -469,11 +469,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
*/
*/
async updateSpaceSettings(v, spaceSlug, body = {}) {
async updateSpaceSettings(v, spaceSlug, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a space');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a space');
- const space = spaceFor(v, spaceSlug);
- const fields = spaceFields(body, space);
- const next = store.updateSpace(db, space.id, fields);
- const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.id === next.id) || next;
- return { space: shapeSpace(row, await membersOnly(row.members_only_owner)) };
+ const space = await spaceFor(v, spaceSlug);
+ const fields = await spaceFields(body, space);
+ const next = await store.updateSpace(db, space.id, fields);
+ const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.id === next.id) || next;
+ return { space: await shapeSpace(row, await membersOnly(row.members_only_owner)) };
},
},
/** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */
/** A new space — moderators. { slug, name, description?, style?, votes?, reactions?, group?, parent?, visibility?, kind? } */
@@ -481,21 +481,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -481,21 +481,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create spaces');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create spaces');
const slug = String(body.slug || '').trim().toLowerCase();
const slug = String(body.slug || '').trim().toLowerCase();
if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A space slug is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');
if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A space slug is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');
- if (store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
+ if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
- const fields = spaceFields({ ...body, name: body.name }, null);
+ const fields = await spaceFields({ ...body, name: body.name }, null);
if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');
if (!fields.name) fail(400, 'space.invalid_name', 'Name the space');
const style = fields.style || 'feed';
const style = fields.style || 'feed';
- store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),
+ await store.createSpace(db, { slug, visibility, created_by: v.subject || v.service || 'staff', style, votes: fields.votes != null ? fields.votes : (style === 'forum' ? 0 : 1),
reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,
reactions: fields.reactions != null ? fields.reactions : 1, name: fields.name, description: fields.description || null, group_id: fields.group_id || null,
parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });
parent_id: fields.parent_id || null, position: fields.position || 0, thread_kind: fields.thread_kind || 'discussion' });
- const row = store.listSpaces(db, ['public', 'members', 'staff']).find((r) => r.slug === slug);
- return { space: shapeSpace(row) };
+ const row = (await store.listSpaces(db, ['public', 'members', 'staff'])).find((r) => r.slug === slug);
+ return { space: await shapeSpace(row) };
},
},
async space(v, slug) {
async space(v, slug) {
- const s = spaceFor(v, slug);
- return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };
+ const s = await spaceFor(v, slug);
+ return { space: await shapeSpace(s, await membersOnly(s.members_only_owner)) };
},
},
/**
/**
@@ -504,34 +504,34 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -504,34 +504,34 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* another room replaces it (Chat is told the space let go of the old one). → { chat_room, created }
* another room replaces it (Chat is told the space let go of the old one). → { chat_room, created }
*/
*/
async attachChatRoom(v, spaceSlug, body = {}) {
async attachChatRoom(v, spaceSlug, body = {}) {
- const space = spaceFor(v, spaceSlug);
+ const space = await spaceFor(v, spaceSlug);
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');
if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff attach a chat room');
if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
if (!v || v.kind !== 'user' || !v.token) fail(403, 'chat_room.person_only', 'Attach a chat room while signed in with your own account');
if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
if (!chatRooms) fail(503, 'chat_room.unavailable', 'Chat rooms cannot be attached right now');
const slug = chatRooms.parseRoomRef(body.room);
const slug = chatRooms.parseRoomRef(body.room);
if (!slug) fail(400, 'chat_room.invalid', 'Name the room by its address (night-owls) or its link (https://openvibe.chat/r/night-owls)');
if (!slug) fail(400, 'chat_room.invalid', 'Name the room by its address (night-owls) or its link (https://openvibe.chat/r/night-owls)');
- const before = chatRoomRow(space.id);
+ const before = await chatRoomRow(space.id);
const out = await chatRooms.attach({ token: v.token, room: slug, space: space.slug, title: space.name });
const out = await chatRooms.attach({ token: v.token, room: slug, space: space.slug, title: space.name });
const same = !!before && before.room_slug === out.room.slug;
const same = !!before && before.room_slug === out.room.slug;
- db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)
+ await db.prepare(`INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name,
ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name,
room_kind = excluded.room_kind, room_visibility = excluded.room_visibility,
room_kind = excluded.room_kind, room_visibility = excluded.room_visibility,
attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END,
attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END,
- attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE CURRENT_TIMESTAMP END`)
+ attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE ov_now() END`)
.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
- return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(chatRoomRow(space.id)), created: !same };
+ return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };
},
},
/** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */
/** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */
async detachChatRoom(v, spaceSlug) {
async detachChatRoom(v, spaceSlug) {
- const space = spaceFor(v, spaceSlug);
+ const space = await spaceFor(v, spaceSlug);
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
if (!person(v) && !moderator(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account');
if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');
if (!canManageChatRoom(v, space)) fail(403, 'capability.denied', 'Only the space\'s owner or staff detach its chat room');
- const before = chatRoomRow(space.id);
+ const before = await chatRoomRow(space.id);
if (!before) return { detached: false, chat: null };
if (!before) return { detached: false, chat: null };
- db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
+ await db.prepare('DELETE FROM space_chat_rooms WHERE space_id = ?').run(space.id);
// Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).
// Chat's side of the link goes too when the person may remove it there (best effort: the space no longer shows it either way).
const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';
const chat = chatRooms && v.kind === 'user' && v.token ? await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug }) : 'unavailable';
return { detached: true, chat };
return { detached: true, chat };
@@ -539,27 +539,27 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -539,27 +539,27 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */
/** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */
async listThreads(v, spaceSlug, q = {}) {
async listThreads(v, spaceSlug, q = {}) {
- const space = spaceFor(v, spaceSlug);
+ const space = await spaceFor(v, spaceSlug);
await requireMembership(v, space);
await requireMembership(v, space);
const forumStyle = space.style === 'forum';
const forumStyle = space.style === 'forum';
let sort = store.SORTS.includes(q.sort) ? q.sort : (forumStyle ? 'active' : 'hot');
let sort = store.SORTS.includes(q.sort) ? q.sort : (forumStyle ? 'active' : 'hot');
if (sort === 'top' && space.votes === 0) sort = forumStyle ? 'active' : 'hot';
if (sort === 'top' && space.votes === 0) sort = forumStyle ? 'active' : 'hot';
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
- const category = q.category ? store.getCategory(db, space.id, q.category) : null;
+ const category = q.category ? await store.getCategory(db, space.id, q.category) : null;
if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');
if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');
const statuses = STATUSES[space.thread_kind] || [];
const statuses = STATUSES[space.thread_kind] || [];
if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');
if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');
- const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
+ const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
const index = await this.listSpaces(v);
const index = await this.listSpaces(v);
const self = index.spaces.find((x) => x.slug === space.slug) || {};
const self = index.spaces.find((x) => x.slug === space.slug) || {};
return {
return {
- space: { ...shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },
+ space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner)), group: self.group || null, parent: self.parent || null },
sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
- categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
+ categories: (await store.listCategories(db, space.id)).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v), can_manage_chat_room: canManageChatRoom(v, space) },
viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v), signed_in: person(v), can_manage_chat_room: canManageChatRoom(v, space) },
children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
- groups: moderator(v) ? store.listGroups(db).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
+ groups: moderator(v) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
threads: await shapeThreads(rows, () => space, v),
threads: await shapeThreads(rows, () => space, v),
};
};
},
},
@@ -571,7 +571,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -571,7 +571,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
*/
*/
async uploadAttachment(v, spaceSlug, file) {
async uploadAttachment(v, spaceSlug, file) {
if (!media || !media.configured) fail(503, 'attachments.unavailable', 'Images cannot be attached right now');
if (!media || !media.configured) fail(503, 'attachments.unavailable', 'Images cannot be attached right now');
- const space = spaceFor(v, spaceSlug);
+ const space = await spaceFor(v, spaceSlug);
if (!person(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account to attach images');
if (!person(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account to attach images');
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space);
await requireMembership(v, space);
@@ -588,7 +588,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -588,7 +588,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
fail(err.status === 413 || err.status === 415 ? err.status : 502, 'attachments.upload_failed', 'The image could not be stored. Try again.');
fail(err.status === 413 || err.status === 415 ? err.status : 502, 'attachments.upload_failed', 'The image could not be stored. Try again.');
}
}
uploadLimiter.record(`s:${v.subject}`);
uploadLimiter.record(`s:${v.subject}`);
- db.prepare('INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)')
.run(stored.id, v.subject, filename, mime, buffer.length, stored.url);
.run(stored.id, v.subject, filename, mime, buffer.length, stored.url);
return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };
return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };
},
},
@@ -600,7 +600,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -600,7 +600,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
*/
*/
async react(v, postId, body = {}) {
async react(v, postId, body = {}) {
if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');
if (!person(v)) fail(401, 'auth.required', 'Sign in to rate posts');
- const { post, thread, space } = postFor(v, postId);
+ const { post, thread, space } = await postFor(v, postId);
if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');
if (space.reactions === 0) fail(403, 'space.reactions_off', 'Ratings are off in this space');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
@@ -608,9 +608,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -608,9 +608,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);
const reaction = body.reaction == null || body.reaction === '' ? null : String(body.reaction);
if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);
if (reaction && !reactions.BY_KEY.has(reaction)) fail(400, 'reaction.invalid', `reaction is one of ${reactions.REACTIONS.map((r) => r.key).join(', ')}`);
reactLimiter.check(`s:${v.subject}`);
reactLimiter.check(`s:${v.subject}`);
- const mine = reactions.setReaction(db, post.id, v.subject, reaction);
+ const mine = await reactions.setReaction(db, post.id, v.subject, reaction);
reactLimiter.record(`s:${v.subject}`);
reactLimiter.record(`s:${v.subject}`);
- const list = reactions.reactionsFor(db, [post.id], v.subject).get(post.id) || [];
+ const list = (await reactions.reactionsFor(db, [post.id], v.subject)).get(post.id) || [];
return { post_id: post.id, mine, reactions: list.map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, count: e.count, mine: e.mine })) };
return { post_id: post.id, mine, reactions: list.map((e) => ({ key: e.key, emoji: e.emoji, label: e.label, count: e.count, mine: e.mine })) };
},
},
@@ -619,32 +619,32 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -619,32 +619,32 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* quotes its opening; both show the link. Not members-only threads; not into roadmap spaces (staff excepted).
* quotes its opening; both show the link. Not members-only threads; not into roadmap spaces (staff excepted).
*/
*/
async crosspost(v, spaceSlug, threadSlug, body = {}) {
async crosspost(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const w = writer(v);
const w = writer(v);
if (!w.author) fail(403, 'crosspost.person_only', 'Only people crosspost');
if (!w.author) fail(403, 'crosspost.person_only', 'Only people crosspost');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');
if (thread.members_only_owner || space.members_only_owner) fail(403, 'crosspost.members_only', 'Members-only threads stay where they are');
- const target = spaceFor(v, String(body.to || ''));
+ const target = await spaceFor(v, String(body.to || ''));
if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');
if (target.id === space.id) fail(400, 'crosspost.same_space', 'Pick another space');
mayPostIn(v, target);
mayPostIn(v, target);
await requireMembership(v, target);
await requireMembership(v, target);
const kind = target.thread_kind || 'discussion';
const kind = target.thread_kind || 'discussion';
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff');
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff');
- if (store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
- const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
+ if (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
+ const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');
const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');
const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
- const { thread: created } = store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,
+ const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,
kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });
kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });
- if (pulse) hook(() => pulse.threadCreated(created, target));
- if (relay) hook(() => relay.enqueueThread(created, target));
+ if (pulse) await hook(async () => await pulse.threadCreated(created, target));
+ if (relay) await hook(async () => await relay.enqueueThread(created, target));
const projections = await authors.projectionsFor([created.author_subject]);
const projections = await authors.projectionsFor([created.author_subject]);
- return { thread: shapeThread(created, target, v, projections, null) };
+ return { thread: await shapeThread(created, target, v, projections, null) };
},
},
/** Quote a post into a reply: "**name** wrote:" and the post as a Markdown quote. → { markdown } */
/** Quote a post into a reply: "**name** wrote:" and the post as a Markdown quote. → { markdown } */
async quote(v, postId) {
async quote(v, postId) {
- const { post, thread, space } = postFor(v, postId);
+ const { post, thread, space } = await postFor(v, postId);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
const projections = await authors.projectionsFor([post.author_subject]);
const projections = await authors.projectionsFor([post.author_subject]);
const a = authors.author(post.author_subject, post.origin, projections, post.relay_author);
const a = authors.author(post.author_subject, post.origin, projections, post.relay_author);
@@ -654,85 +654,85 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -654,85 +654,85 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
},
},
/** A page view of a thread (the forum's Views): once per viewer per 30 minutes. */
/** A page view of a thread (the forum's Views): once per viewer per 30 minutes. */
- recordView(threadId, viewerKey) {
+ async recordView(threadId, viewerKey) {
const key = `${threadId}|${viewerKey || '?'}`;
const key = `${threadId}|${viewerKey || '?'}`;
const now = Date.now();
const now = Date.now();
const seen = viewSeen.get(key);
const seen = viewSeen.get(key);
if (seen && now - seen < 30 * 60 * 1000) return false;
if (seen && now - seen < 30 * 60 * 1000) return false;
if (viewSeen.size > 50000) viewSeen.clear();
if (viewSeen.size > 50000) viewSeen.clear();
viewSeen.set(key, now);
viewSeen.set(key, now);
- store.bumpViews(db, threadId);
+ await store.bumpViews(db, threadId);
return true;
return true;
},
},
/** The categories of a space. */
/** The categories of a space. */
- categories(v, spaceSlug) {
- const space = spaceFor(v, spaceSlug);
- return { space: space.slug, categories: store.listCategories(db, space.id).map(shapeCategory) };
+ async categories(v, spaceSlug) {
+ const space = await spaceFor(v, spaceSlug);
+ return { space: space.slug, categories: (await store.listCategories(db, space.id)).map(shapeCategory) };
},
},
/** Create or change a category { slug, name, description?, position? } — moderators. */
/** Create or change a category { slug, name, description?, position? } — moderators. */
- putCategory(v, spaceSlug, slug, body = {}) {
+ async putCategory(v, spaceSlug, slug, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
- const space = spaceFor(v, spaceSlug);
+ const space = await spaceFor(v, spaceSlug);
if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');
if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');
const name = cleanTitle(body.name);
const name = cleanTitle(body.name);
if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');
if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');
const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;
const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;
const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
- return { category: shapeCategory(store.upsertCategory(db, space.id, { slug, name, description, position })) };
+ return { category: shapeCategory(await store.upsertCategory(db, space.id, { slug, name, description, position })) };
},
},
/** Delete a category — moderators. Its threads stay, without a category. */
/** Delete a category — moderators. Its threads stay, without a category. */
- deleteCategory(v, spaceSlug, slug) {
+ async deleteCategory(v, spaceSlug, slug) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
- const space = spaceFor(v, spaceSlug);
- if (!store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
+ const space = await spaceFor(v, spaceSlug);
+ if (!await store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
return { ok: true };
return { ok: true };
},
},
/** Move a thread to a category { category: slug | null } — its author or a moderator. */
/** Move a thread to a category { category: slug | null } — its author or a moderator. */
async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {
async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
- const category = body.category ? store.getCategory(db, space.id, body.category) : null;
+ const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
- const next = store.setThreadCategory(db, thread.id, category ? category.id : null);
+ const next = await store.setThreadCategory(db, thread.id, category ? category.id : null);
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
- return { thread: shapeThread(next, space, v, projections, null) };
+ return { thread: await shapeThread(next, space, v, projections, null) };
},
},
/** A request's or roadmap item's status { status } — moderators. */
/** A request's or roadmap item's status { status } — moderators. */
async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {
async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a status');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a status');
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const statuses = STATUSES[thread.kind] || [];
const statuses = STATUSES[thread.kind] || [];
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
- const next = store.setThreadStatus(db, thread.id, body.status);
+ const next = await store.setThreadStatus(db, thread.id, body.status);
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
- return { thread: shapeThread(next, space, v, projections, null) };
+ return { thread: await shapeThread(next, space, v, projections, null) };
},
},
/** A thread with a page of its posts. ?page= */
/** A thread with a page of its posts. ?page= */
async getThread(v, spaceSlug, threadSlug, q = {}) {
async getThread(v, spaceSlug, threadSlug, q = {}) {
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
- const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
+ const { rows, total } = await store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);
- const votes = myVotes(db, 'thread', [thread.id], v && v.subject);
+ const votes = await myVotes(db, 'thread', [thread.id], v && v.subject);
const ids = rows.map((p) => p.id);
const ids = rows.map((p) => p.id);
- const att = attachmentsOf(ids);
- const pasted = pastesOf(ids);
- const rated = space.reactions !== 0 ? reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();
+ const att = await attachmentsOf(ids);
+ const pasted = await pastesOf(ids);
+ const rated = space.reactions !== 0 ? await reactions.reactionsFor(db, ids, person(v) ? v.subject : null) : new Map();
const authorSubjects = rows.map((p) => p.author_subject);
const authorSubjects = rows.map((p) => p.author_subject);
- const stats = store.authorStats(db, authorSubjects);
- const received = space.reactions !== 0 ? reactions.receivedBy(db, authorSubjects) : new Map();
+ const stats = await store.authorStats(db, authorSubjects);
+ const received = space.reactions !== 0 ? await reactions.receivedBy(db, authorSubjects) : new Map();
const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));
const raterProjections = await authors.projectionsFor([...rated.values()].flatMap((l) => l.flatMap((e) => e.raters)));
return {
return {
- space: { ...shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...placeOf(space) },
- thread: shapeThread(thread, space, v, projections, votes),
+ space: { ...await shapeSpace(space, await membersOnly(space.members_only_owner, projections)), ...await placeOf(space) },
+ thread: await shapeThread(thread, space, v, projections, votes),
posts: rows.map((p) => {
posts: rows.map((p) => {
const sp = shapePost(p, v, projections, att, pasted);
const sp = shapePost(p, v, projections, att, pasted);
const st = p.author_subject ? stats.get(p.author_subject) : null;
const st = p.author_subject ? stats.get(p.author_subject) : null;
@@ -743,8 +743,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -743,8 +743,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return sp;
return sp;
}),
}),
reactions: space.reactions !== 0 ? reactions.REACTIONS : [],
reactions: space.reactions !== 0 ? reactions.REACTIONS : [],
- crosspost: crosspostInfo(v, thread),
- categories: store.listCategories(db, space.id).map(shapeCategory),
+ crosspost: await crosspostInfo(v, thread),
+ categories: (await store.listCategories(db, space.id)).map(shapeCategory),
attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },
attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
viewer: {
viewer: {
@@ -763,101 +763,101 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -763,101 +763,101 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* the author's own VIP members; { owner } names the creator (moderators, or the author themselves).
* the author's own VIP members; { owner } names the creator (moderators, or the author themselves).
*/
*/
async createThread(v, spaceSlug, body = {}) {
async createThread(v, spaceSlug, body = {}) {
- const space = spaceFor(v, spaceSlug);
+ const space = await spaceFor(v, spaceSlug);
const w = writer(v);
const w = writer(v);
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space);
await requireMembership(v, space);
const gate = gateOwner(v, body.members_only, w.author);
const gate = gateOwner(v, body.members_only, w.author);
const kind = space.thread_kind || 'discussion';
const kind = space.thread_kind || 'discussion';
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
- const category = body.category ? store.getCategory(db, space.id, body.category) : null;
+ const category = body.category ? await store.getCategory(db, space.id, body.category) : null;
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
const title = cleanTitle(body.title);
const title = cleanTitle(body.title);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
- const images = claimable(w, body.attachments);
- const pasteRows = claimPastes(body.pastes);
+ const images = await claimable(w, body.attachments);
+ const pasteRows = await claimPastes(body.pastes);
if (w.key) {
if (w.key) {
threadLimiter.check(w.key, title);
threadLimiter.check(w.key, title);
- if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
+ if (threadsPerDay > 0 && await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
}
}
- const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,
+ const { thread, post } = await store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,
kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
threadLimiter.record(w.key, title);
threadLimiter.record(w.key, title);
- attach(images, post.id);
- attachPastes(pasteRows, post.id);
- if (pulse) hook(() => pulse.threadCreated(thread, space));
- if (relay) hook(() => relay.enqueueThread(thread, space));
+ await attach(images, post.id);
+ await attachPastes(pasteRows, post.id);
+ if (pulse) await hook(async () => await pulse.threadCreated(thread, space));
+ if (relay) await hook(async () => await relay.enqueueThread(thread, space));
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
- return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])) };
+ return { thread: await shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])) };
},
},
/** Reply { body } → { post } */
/** Reply { body } → { post } */
async reply(v, spaceSlug, threadSlug, body = {}) {
async reply(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const w = writer(v);
const w = writer(v);
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
// Platform blocks: nobody replies in a thread whose author blocked them.
// Platform blocks: nobody replies in a thread whose author blocked them.
- blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
+ await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
- const images = claimable(w, body.attachments);
- const pasteRows = claimPastes(body.pastes);
+ const images = await claimable(w, body.attachments);
+ const pasteRows = await claimPastes(body.pastes);
postLimiter.check(w.key, text);
postLimiter.check(w.key, text);
- const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
+ const post = await store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
postLimiter.record(w.key, text);
postLimiter.record(w.key, text);
- attach(images, post.id);
- attachPastes(pasteRows, post.id);
- if (pulse) hook(() => pulse.postCreated(post, thread, space));
- if (relay) hook(() => relay.enqueuePost(post, thread, space));
+ await attach(images, post.id);
+ await attachPastes(pasteRows, post.id);
+ if (pulse) await hook(async () => await pulse.postCreated(post, thread, space));
+ if (relay) await hook(async () => await relay.enqueuePost(post, thread, space));
const projections = await authors.projectionsFor([post.author_subject]);
const projections = await authors.projectionsFor([post.author_subject]);
// Where the new post lands: its page in the thread (posts are numbered in id order).
// Where the new post lands: its page in the thread (posts are numbered in id order).
- const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;
+ const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
- return { post: shapePost(post, v, projections, attachmentsOf([post.id]), pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
+ return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
},
},
/** Edit { body } — the author (not on a locked thread) or a moderator. */
/** Edit { body } — the author (not on a locked thread) or a moderator. */
async editPost(v, postId, body = {}) {
async editPost(v, postId, body = {}) {
- const { post, thread, space } = postFor(v, postId);
+ const { post, thread, space } = await postFor(v, postId);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
const mine = person(v) && post.author_subject === v.subject;
const mine = person(v) && post.author_subject === v.subject;
if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
- const next = store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
- if (relay && next && next.revision !== post.revision) hook(() => relay.enqueueEdit(next));
+ const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
+ if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
return { post: shapePost(next, v, projections) };
return { post: shapePost(next, v, projections) };
},
},
/** Edit history — the author or a moderator. */
/** Edit history — the author or a moderator. */
async postVersions(v, postId) {
async postVersions(v, postId) {
- const { post, thread, space } = postFor(v, postId);
+ const { post, thread, space } = await postFor(v, postId);
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
- const list = store.listPostVersions(db, post.id).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
+ const list = (await store.listPostVersions(db, post.id)).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
},
},
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
/** Delete a post — the author or a moderator. Deleting the opening post deletes the thread. */
- deletePost(v, postId) {
- const { post, thread } = postFor(v, postId);
+ async deletePost(v, postId) {
+ const { post, thread } = await postFor(v, postId);
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
- if (post.is_opening) return removeThread(v, thread);
- db.transaction(() => {
- store.softDeletePost(db, post.id);
- if (!(person(v) && post.author_subject === v.subject)) events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
- })();
- if (pulse) hook(() => pulse.postGone(post.id));
- if (relay) hook(() => relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));
+ if (post.is_opening) return await removeThread(v, thread);
+ await db.tx(async () => {
+ await store.softDeletePost(db, post.id);
+ if (!(person(v) && post.author_subject === v.subject)) await events.moderationAction(v, 'post.deleted', { type: 'post', id: String(post.id), owner_subject: post.author_subject || null }, { details: { thread: String(thread.id) } });
+ });
+ if (pulse) await hook(async () => await pulse.postGone(post.id));
+ if (relay) await hook(async () => await relay.enqueueDelete(thread.id, post.id, person(v) && post.author_subject === v.subject ? 'deleted by its author' : 'deleted by a moderator'));
return { ok: true, id: post.id };
return { ok: true, id: post.id };
},
},
/** Delete a thread — its author or a moderator. */
/** Delete a thread — its author or a moderator. */
- deleteThread(v, spaceSlug, threadSlug) {
- const { thread } = threadFor(v, spaceSlug, threadSlug);
- return removeThread(v, thread);
+ async deleteThread(v, spaceSlug, threadSlug) {
+ const { thread } = await threadFor(v, spaceSlug, threadSlug);
+ return await removeThread(v, thread);
},
},
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
@@ -865,12 +865,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -865,12 +865,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const value = parseVote(body.value);
const value = parseVote(body.value);
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');
if (space.votes === 0) fail(403, 'space.votes_off', 'Votes are off in this space');
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
voteLimiter.check(`s:${v.subject}`);
voteLimiter.check(`s:${v.subject}`);
- const out = applyVote(db, 'thread', thread.id, v.subject, value);
+ const out = await applyVote(db, 'thread', thread.id, v.subject, value);
voteLimiter.record(`s:${v.subject}`);
voteLimiter.record(`s:${v.subject}`);
return { thread_id: thread.id, ...out };
return { thread_id: thread.id, ...out };
},
},
@@ -878,18 +878,18 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -878,18 +878,18 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
/** Pin / lock { pinned?, locked? } — moderators. */
/** Pin / lock { pinned?, locked? } — moderators. */
async moderateThread(v, spaceSlug, threadSlug, body = {}) {
async moderateThread(v, spaceSlug, threadSlug, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators pin or lock threads');
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const flags = {};
const flags = {};
if (body.pinned !== undefined) flags.pinned = !!body.pinned;
if (body.pinned !== undefined) flags.pinned = !!body.pinned;
if (body.locked !== undefined) flags.locked = !!body.locked;
if (body.locked !== undefined) flags.locked = !!body.locked;
if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');
if (!Object.keys(flags).length) fail(400, 'thread.nothing_to_change', 'Send pinned and/or locked');
- const next = db.transaction(() => {
- const r = store.setThreadFlags(db, thread.id, flags);
- if (flags.locked !== undefined && !!thread.locked !== flags.locked) events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
+ const next = await db.tx(async () => {
+ const r = await store.setThreadFlags(db, thread.id, flags);
+ if (flags.locked !== undefined && !!thread.locked !== flags.locked) await events.moderationAction(v, flags.locked ? 'thread.locked' : 'thread.unlocked', { type: 'thread', id: String(thread.id), owner_subject: thread.author_subject || null });
return r;
return r;
- })();
+ });
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
- return { thread: shapeThread(next, space, v, projections, null) };
+ return { thread: await shapeThread(next, space, v, projections, null) };
},
},
/**
/**
@@ -898,39 +898,39 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -898,39 +898,39 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
* Pulse and cancels pending Discord relay deliveries.
* Pulse and cancels pending Discord relay deliveries.
*/
*/
async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {
async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {
- const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const mine = person(v) && thread.author_subject === v.subject;
const mine = person(v) && thread.author_subject === v.subject;
if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');
if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');
await requireMembership(v, space, null);
await requireMembership(v, space, null);
const requested = body.members_only !== undefined ? body.members_only : body.owner;
const requested = body.members_only !== undefined ? body.members_only : body.owner;
const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject);
const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject);
if (!owner && thread.members_only_owner && !moderator(v) && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');
if (!owner && thread.members_only_owner && !moderator(v) && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');
- const next = store.setThreadMembersOnly(db, thread.id, owner);
- if (owner) hideGated([thread.id]);
+ const next = await store.setThreadMembersOnly(db, thread.id, owner);
+ if (owner) await hideGated([thread.id]);
const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
- return { thread: shapeThread(next, space, v, projections, null) };
+ return { thread: await shapeThread(next, space, v, projections, null) };
},
},
/** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */
/** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */
async setSpaceMembersOnly(v, spaceSlug, body = {}) {
async setSpaceMembersOnly(v, spaceSlug, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators make a space members-only');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators make a space members-only');
- const space = spaceFor(v, spaceSlug);
+ const space = await spaceFor(v, spaceSlug);
const requested = body.members_only !== undefined ? body.members_only : body.owner;
const requested = body.members_only !== undefined ? body.members_only : body.owner;
const owner = gateOwner(v, requested === undefined ? null : requested, null);
const owner = gateOwner(v, requested === undefined ? null : requested, null);
- const next = store.setSpaceMembersOnly(db, space.id, owner);
- if (owner) hideGated(db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id).map((r) => r.id));
- return { space: shapeSpace(next, await membersOnly(next.members_only_owner)) };
+ const next = await store.setSpaceMembersOnly(db, space.id, owner);
+ if (owner) await hideGated((await db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id)).map((r) => r.id));
+ return { space: await shapeSpace(next, await membersOnly(next.members_only_owner)) };
},
},
/** Latest threads in public spaces, with their opening post (sitemap, feeds). */
/** Latest threads in public spaces, with their opening post (sitemap, feeds). */
- recentPublic({ limit = 50, space = null } = {}) {
- const rows = store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });
+ async recentPublic({ limit = 50, space = null } = {}) {
+ const rows = await store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });
const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1');
const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1');
- return rows.map((t) => ({ ...t, opening: (opening.get(t.id) || {}).body_markdown || '' }));
+ return (await Promise.all(rows.map(async (t) => ({ ...t, opening: (await opening.get(t.id) || {}).body_markdown || '' }))));
},
},
/** Public, open spaces (sitemap, feeds): members-only spaces are left out. */
/** Public, open spaces (sitemap, feeds): members-only spaces are left out. */
- publicSpaces() { return store.listSpaces(db, ['public']).filter((s) => !s.members_only_owner).map((s) => shapeSpace(s)); },
+ async publicSpaces() { return (await Promise.all((await store.listSpaces(db, ['public'])).filter((s) => !s.members_only_owner).map(async (s) => await shapeSpace(s)))); },
};
};
}
}
@@ -13,8 +13,8 @@ const SORTS = ['hot', 'new', 'top', 'active'];
@@ -13,8 +13,8 @@ const SORTS = ['hot', 'new', 'top', 'active'];
// ── Spaces ───────────────────────────────────────────────────
// ── Spaces ───────────────────────────────────────────────────
-function listSpaces(db, visibilities) {
- return db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,
+async function listSpaces(db, visibilities) {
+ return await db.prepare(`SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position,
ps.slug AS parent_slug, ps.name AS parent_name,
ps.slug AS parent_slug, ps.name AS parent_name,
(SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,
(SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count,
(SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count,
(SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count,
@@ -28,56 +28,56 @@ function listSpaces(db, visibilities) {
@@ -28,56 +28,56 @@ function listSpaces(db, visibilities) {
* The newest live post in each space (the board index's "Last post"), members-only threads never.
* The newest live post in each space (the board index's "Last post"), members-only threads never.
* → Map spaceId → { thread_slug, thread_title, author_subject, origin, created_at }
* → Map spaceId → { thread_slug, thread_title, author_subject, origin, created_at }
*/
*/
-function lastPosts(db, spaceIds) {
+async function lastPosts(db, spaceIds) {
const out = new Map();
const out = new Map();
const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id
const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id
FROM posts p JOIN threads t ON t.id = p.thread_id
FROM posts p JOIN threads t ON t.id = p.thread_id
WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL
WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL
ORDER BY p.id DESC LIMIT 1`);
ORDER BY p.id DESC LIMIT 1`);
- for (const id of spaceIds) { const r = q.get(id); if (r) out.set(id, r); }
+ for (const id of spaceIds) { const r = await q.get(id); if (r) out.set(id, r); }
return out;
return out;
}
}
-function listGroups(db) {
- return db.prepare('SELECT * FROM space_groups ORDER BY position, name COLLATE NOCASE').all();
+async function listGroups(db) {
+ return await db.prepare('SELECT * FROM space_groups ORDER BY position, lower(name)').all();
}
}
/** Space settings (moderators): style, votes, reactions, group, parent, position, name, description. */
/** Space settings (moderators): style, votes, reactions, group, parent, position, name, description. */
-function updateSpace(db, id, fields) {
+async function updateSpace(db, id, fields) {
const allowed = ['style', 'votes', 'reactions', 'group_id', 'parent_id', 'position', 'name', 'description', 'thread_kind'];
const allowed = ['style', 'votes', 'reactions', 'group_id', 'parent_id', 'position', 'name', 'description', 'thread_kind'];
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
- if (keys.length) db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });
- return getSpaceById(db, id);
+ if (keys.length) await db.prepare(`UPDATE spaces SET ${keys.map((k) => `${k} = @${k}`).join(', ')} WHERE id = @id`).run({ ...Object.fromEntries(keys.map((k) => [k, fields[k]])), id });
+ return await getSpaceById(db, id);
}
}
-function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {
- db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)
+async function createSpace(db, { slug, name, description = null, visibility = 'public', created_by, style = 'feed', votes = 1, reactions = 1, group_id = null, parent_id = null, position = 0, thread_kind = 'discussion' }) {
+ await db.prepare(`INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind);
- return getSpace(db, slug);
+ return await getSpace(db, slug);
}
}
/** A person's forum statistics for the author panel. → Map subject → { posts, first_post_at } */
/** A person's forum statistics for the author panel. → Map subject → { posts, first_post_at } */
-function authorStats(db, subjects) {
+async function authorStats(db, subjects) {
const out = new Map();
const out = new Map();
const list = [...new Set(subjects.filter(Boolean))];
const list = [...new Set(subjects.filter(Boolean))];
if (!list.length) return out;
if (!list.length) return out;
- const rows = db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts
+ const rows = await db.prepare(`SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts
WHERE author_subject IN (${list.map(() => '?').join(', ')}) AND deleted_at IS NULL GROUP BY author_subject`).all(...list);
WHERE author_subject IN (${list.map(() => '?').join(', ')}) AND deleted_at IS NULL GROUP BY author_subject`).all(...list);
for (const r of rows) out.set(r.s, { posts: r.posts, first_post_at: r.first_post_at });
for (const r of rows) out.set(r.s, { posts: r.posts, first_post_at: r.first_post_at });
return out;
return out;
}
}
/** One more view of a thread (the forum's Views column). */
/** One more view of a thread (the forum's Views column). */
-function bumpViews(db, threadId) {
- db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);
+async function bumpViews(db, threadId) {
+ await db.prepare('UPDATE threads SET views = views + 1 WHERE id = ?').run(threadId);
}
}
-function getSpace(db, slug) {
- return db.prepare('SELECT * FROM spaces WHERE slug = ?').get(String(slug)) || null;
+async function getSpace(db, slug) {
+ return await db.prepare('SELECT * FROM spaces WHERE slug = ?').get(String(slug)) || null;
}
}
-function getSpaceById(db, id) {
- return db.prepare('SELECT * FROM spaces WHERE id = ?').get(id) || null;
+async function getSpaceById(db, id) {
+ return await db.prepare('SELECT * FROM spaces WHERE id = ?').get(id) || null;
}
}
// ── Threads ──────────────────────────────────────────────────
// ── Threads ──────────────────────────────────────────────────
@@ -89,39 +89,39 @@ function slugify(title) {
@@ -89,39 +89,39 @@ function slugify(title) {
return s || 'thread';
return s || 'thread';
}
}
-function uniqueSlug(db, spaceId, base) {
+async function uniqueSlug(db, spaceId, base) {
const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');
const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');
- if (!taken.get(spaceId, base)) return base;
+ if (!await taken.get(spaceId, base)) return base;
for (let n = 2; n < 10_000; n++) {
for (let n = 2; n < 10_000; n++) {
const candidate = `${base.slice(0, 54)}-${n}`;
const candidate = `${base.slice(0, 54)}-${n}`;
- if (!taken.get(spaceId, candidate)) return candidate;
+ if (!await taken.get(spaceId, candidate)) return candidate;
}
}
throw new Error('Could not find a free thread slug');
throw new Error('Could not find a free thread slug');
}
}
-function getThread(db, id) {
- return db.prepare('SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL').get(id) || null;
+async function getThread(db, id) {
+ return await db.prepare('SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL').get(id) || null;
}
}
-function getThreadBySlug(db, spaceId, slug) {
- return db.prepare('SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL').get(spaceId, String(slug)) || null;
+async function getThreadBySlug(db, spaceId, slug) {
+ return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL').get(spaceId, String(slug)) || null;
}
}
/** New thread + its opening post, in one transaction. → { thread, post } */
/** New thread + its opening post, in one transaction. → { thread, post } */
-function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {
- return db.transaction(() => {
- const slug = uniqueSlug(db, space_id, slugify(title));
- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
+async function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null, crosspost_of = null }) {
+ return await db.tx(async () => {
+ const slug = await uniqueSlug(db, space_id, slugify(title));
+ const info = await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')
.run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);
.run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of);
const threadId = info.lastInsertRowid;
const threadId = info.lastInsertRowid;
- const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
+ const p = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?) RETURNING id')
.run(threadId, author_subject, origin, body_markdown);
.run(threadId, author_subject, origin, body_markdown);
- const thread = getThread(db, threadId);
- const space = db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);
+ const thread = await getThread(db, threadId);
+ const space = await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);
// A members-only (VIP) space's threads are not public either.
// A members-only (VIP) space's threads are not public either.
- require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.thread.created
- return { thread, post: getPost(db, p.lastInsertRowid) };
- })();
+ await require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.thread.created
+ return { thread, post: await getPost(db, p.lastInsertRowid) };
+ });
}
}
/**
/**
@@ -131,7 +131,7 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
@@ -131,7 +131,7 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
* top highest score first
* top highest score first
* → { rows, total }
* → { rows, total }
*/
*/
-function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {
+async function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {
const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
const order = {
const order = {
hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
@@ -139,128 +139,128 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =
@@ -139,128 +139,128 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =
top: 'pinned DESC, score DESC, created_at DESC, id DESC',
top: 'pinned DESC, score DESC, created_at DESC, id DESC',
active: 'pinned DESC, last_activity_at DESC, id DESC',
active: 'pinned DESC, last_activity_at DESC, id DESC',
}[SORTS.includes(sort) ? sort : 'hot'];
}[SORTS.includes(sort) ? sort : 'hot'];
- const where = 'space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)';
+ const where = 'space_id = @space AND deleted_at IS NULL AND (@category::bigint IS NULL OR category_id = @category) AND (@status::text IS NULL OR status = @status)';
const params = { space: spaceId, category: categoryId, status };
const params = { space: spaceId, category: categoryId, status };
// last_* : the newest live post, for the forum's "Last post" column.
// last_* : the newest live post, for the forum's "Last post" column.
- const rows = db.prepare(`SELECT threads.*,
+ const rows = await db.prepare(`SELECT threads.*,
(SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject,
(SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject,
(SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin,
(SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin,
(SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id
(SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id
FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)
FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)
.all({ ...params, now: nowSql, limit, offset });
.all({ ...params, now: nowSql, limit, offset });
- const { total } = db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);
+ const { total } = await db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);
return { rows, total };
return { rows, total };
}
}
// ── Categories (WS-J task 1) ─────────────────────────────────
// ── Categories (WS-J task 1) ─────────────────────────────────
-function listCategories(db, spaceId) {
- return db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count
- FROM categories c WHERE c.space_id = ? ORDER BY c.position, c.name COLLATE NOCASE`).all(spaceId);
+async function listCategories(db, spaceId) {
+ return await db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count
+ FROM categories c WHERE c.space_id = ? ORDER BY c.position, lower(c.name)`).all(spaceId);
}
}
-function getCategory(db, spaceId, slug) {
- return db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;
+async function getCategory(db, spaceId, slug) {
+ return await db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;
}
}
-function getCategoryById(db, id) {
- return id ? db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;
+async function getCategoryById(db, id) {
+ return id ? await db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;
}
}
-function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {
- db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)
+async function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {
+ await db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
.run(spaceId, slug, name, description, position);
.run(spaceId, slug, name, description, position);
- return getCategory(db, spaceId, slug);
+ return await getCategory(db, spaceId, slug);
}
}
/** Threads keep their place; they just lose the category. */
/** Threads keep their place; they just lose the category. */
-function deleteCategory(db, spaceId, slug) {
- return db.transaction(() => {
- const c = getCategory(db, spaceId, slug);
+async function deleteCategory(db, spaceId, slug) {
+ return await db.tx(async () => {
+ const c = await getCategory(db, spaceId, slug);
if (!c) return false;
if (!c) return false;
- db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);
- db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);
+ await db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);
+ await db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);
return true;
return true;
- })();
+ });
}
}
-function setThreadCategory(db, id, categoryId) {
- db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);
- return getThread(db, id);
+async function setThreadCategory(db, id, categoryId) {
+ await db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);
+ return await getThread(db, id);
}
}
-function setThreadStatus(db, id, status) {
- db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);
- return getThread(db, id);
+async function setThreadStatus(db, id, status) {
+ await db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);
+ return await getThread(db, id);
}
}
-function getThreadByKey(db, spaceId, key) {
- return db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;
+async function getThreadByKey(db, spaceId, key) {
+ return await db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;
}
}
/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */
/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */
-function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
- return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
- WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (? IS NULL OR s.slug = ?)
+async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
+ return await db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
+ WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (?::text IS NULL OR s.slug = ?)
AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL
AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL
ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
}
}
-function setThreadFlags(db, id, { pinned, locked }) {
+async function setThreadFlags(db, id, { pinned, locked }) {
const sets = [], params = [];
const sets = [], params = [];
if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }
if (pinned !== undefined) { sets.push('pinned = ?'); params.push(pinned ? 1 : 0); }
if (locked !== undefined) { sets.push('locked = ?'); params.push(locked ? 1 : 0); }
if (locked !== undefined) { sets.push('locked = ?'); params.push(locked ? 1 : 0); }
- if (sets.length) db.prepare(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
- return getThread(db, id);
+ if (sets.length) await db.prepare(`UPDATE threads SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
+ return await getThread(db, id);
}
}
/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */
/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */
-function setThreadMembersOnly(db, id, owner) {
- db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
- return getThread(db, id);
+async function setThreadMembersOnly(db, id, owner) {
+ await db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
+ return await getThread(db, id);
}
}
-function setSpaceMembersOnly(db, id, owner) {
- db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
- return getSpaceById(db, id);
+async function setSpaceMembersOnly(db, id, owner) {
+ await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
+ return await getSpaceById(db, id);
}
}
-function softDeleteThread(db, id) {
- return db.prepare('UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(id).changes;
+async function softDeleteThread(db, id) {
+ return (await db.prepare('UPDATE threads SET deleted_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(id)).changes;
}
}
/** Threads a subject opened since `sinceSql` (SQLite modifier, e.g. '-1 day'); deleted ones count. */
/** Threads a subject opened since `sinceSql` (SQLite modifier, e.g. '-1 day'); deleted ones count. */
-function countThreadsSince(db, subject, sinceSql) {
- return db.prepare("SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;
+async function countThreadsSince(db, subject, sinceSql) {
+ return (await db.prepare("SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
}
}
// ── Posts ────────────────────────────────────────────────────
// ── Posts ────────────────────────────────────────────────────
-function getPost(db, id) {
- return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) || null;
+async function getPost(db, id) {
+ return await db.prepare('SELECT * FROM posts WHERE id = ?').get(id) || null;
}
}
/**
/**
* A reply; bumps the thread's reply_count and last activity. relay_author: the name a reply written
* A reply; bumps the thread's reply_count and last activity. relay_author: the name a reply written
* on Discord carries (origin 'discord', no author subject; server/relay/inbound.js).
* on Discord carries (origin 'discord', no author subject; server/relay/inbound.js).
*/
*/
-function addPost(db, { thread_id, author_subject = null, origin = 'user', body_markdown, relay_author = null }) {
- return db.transaction(() => {
- const info = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?)')
+async function addPost(db, { thread_id, author_subject = null, origin = 'user', body_markdown, relay_author = null }) {
+ return await db.tx(async () => {
+ const info = await db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?) RETURNING id')
.run(thread_id, author_subject, origin, body_markdown, relay_author);
.run(thread_id, author_subject, origin, body_markdown, relay_author);
- db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
- const post = getPost(db, info.lastInsertRowid);
- const thread = getThread(db, thread_id);
- const space = thread && db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);
- if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.post.created
+ await db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = ov_now() WHERE id = ?').run(thread_id);
+ const post = await getPost(db, info.lastInsertRowid);
+ const thread = await getThread(db, thread_id);
+ const space = thread && await db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);
+ if (thread) await require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.post.created
return post;
return post;
- })();
+ });
}
}
/** Page of a thread's posts in order (tombstones included). → { rows, total } */
/** Page of a thread's posts in order (tombstones included). → { rows, total } */
-function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {
- const rows = db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?').all(threadId, limit, offset);
- const { total } = db.prepare('SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?').get(threadId);
+async function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {
+ const rows = await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?').all(threadId, limit, offset);
+ const { total } = await db.prepare('SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?').get(threadId);
return { rows, total };
return { rows, total };
}
}
@@ -268,38 +268,38 @@ function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {
@@ -268,38 +268,38 @@ function listPosts(db, threadId, { limit = 50, offset = 0 } = {}) {
* Edit a post: revision + 1 and a post_versions row. The first edit also snapshots the original
* Edit a post: revision + 1 and a post_versions row. The first edit also snapshots the original
* as revision 1, so the history is complete without copying posts nobody edits (as pastes do).
* as revision 1, so the history is complete without copying posts nobody edits (as pastes do).
*/
*/
-function editPost(db, id, body, editedBy = null) {
- return db.transaction(() => {
- const cur = getPost(db, id);
+async function editPost(db, id, body, editedBy = null) {
+ return await db.tx(async () => {
+ const cur = await getPost(db, id);
if (!cur || cur.deleted_at || cur.body_markdown === body) return cur;
if (!cur || cur.deleted_at || cur.body_markdown === body) return cur;
- if (!db.prepare('SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?').get(id, cur.revision)) {
- db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)')
+ if (!await db.prepare('SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?').get(id, cur.revision)) {
+ await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)')
.run(id, cur.revision, cur.body_markdown, cur.author_subject, cur.updated_at || cur.created_at);
.run(id, cur.revision, cur.body_markdown, cur.author_subject, cur.updated_at || cur.created_at);
}
}
- db.prepare('UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(body, id);
- const next = getPost(db, id);
- db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)').run(id, next.revision, body, editedBy);
+ await db.prepare('UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = ov_now() WHERE id = ?').run(body, id);
+ const next = await getPost(db, id);
+ await db.prepare('INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)').run(id, next.revision, body, editedBy);
return next;
return next;
- })();
+ });
}
}
-function listPostVersions(db, postId) {
- return db.prepare('SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC').all(postId);
+async function listPostVersions(db, postId) {
+ return await db.prepare('SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC').all(postId);
}
}
/** Soft delete (body scrubbed); the thread's reply_count is recounted. */
/** Soft delete (body scrubbed); the thread's reply_count is recounted. */
-function softDeletePost(db, id) {
- return db.transaction(() => {
- const p = getPost(db, id);
+async function softDeletePost(db, id) {
+ return await db.tx(async () => {
+ const p = await getPost(db, id);
if (!p || p.deleted_at) return 0;
if (!p || p.deleted_at) return 0;
- db.prepare("UPDATE posts SET deleted_at = CURRENT_TIMESTAMP, body_markdown = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
- db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?').run(p.thread_id, p.thread_id);
+ await db.prepare("UPDATE posts SET deleted_at = ov_now(), body_markdown = '', updated_at = ov_now() WHERE id = ?").run(id);
+ await db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?').run(p.thread_id, p.thread_id);
return 1;
return 1;
- })();
+ });
}
}
-function countPostsSince(db, subject, sinceSql) {
- return db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;
+async function countPostsSince(db, subject, sinceSql) {
+ return (await db.prepare("SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
}
}
module.exports = {
module.exports = {
@@ -24,20 +24,10 @@ const TOPICS = ['network.account.export_requested', 'network.account.deleted'];
@@ -24,20 +24,10 @@ const TOPICS = ['network.account.export_requested', 'network.account.deleted'];
const TOMBSTONE = '[deleted]';
const TOMBSTONE = '[deleted]';
const ROW_LIMIT = 5000;
const ROW_LIMIT = 5000;
-const hasTable = (db, t) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);
-const hasColumn = (db, table, col) => { try { return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col); } catch { return false; } };
+const hasTable = async (db, t) => !!await db.prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?').get(t);
+const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);
const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
-function ensureSchema(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- subject TEXT NOT NULL,
- outcome TEXT,
- sent_at TEXT,
- applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
- )`);
-}
// ── Export ─────────────────────────────────────────────────────
// ── Export ─────────────────────────────────────────────────────
@@ -49,12 +39,14 @@ const EXPORTS = [
@@ -49,12 +39,14 @@ const EXPORTS = [
['activity.json', 'pulse_items', 'actor_subject'],
['activity.json', 'pulse_items', 'actor_subject'],
];
];
-function exportPart(db, subject) {
+async function exportPart(db, subject) {
const files = [];
const files = [];
const truncated = [];
const truncated = [];
for (const [name, table, col] of EXPORTS) {
for (const [name, table, col] of EXPORTS) {
- if (!hasColumn(db, table, col)) continue;
- const rows = db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);
+ if (!await hasColumn(db, table, col)) continue;
+ // Newest first: by id where the table has one, else by its timestamp.
+ const order = await hasColumn(db, table, 'id') ? 'id' : await hasColumn(db, table, 'created_at') ? 'created_at' : 'updated_at';
+ const rows = await db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY ${order} DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);
if (!rows.length) continue;
if (!rows.length) continue;
if (rows.length > ROW_LIMIT) truncated.push(name);
if (rows.length > ROW_LIMIT) truncated.push(name);
files.push({ name, content: rows.slice(0, ROW_LIMIT) });
files.push({ name, content: rows.slice(0, ROW_LIMIT) });
@@ -65,42 +57,42 @@ function exportPart(db, subject) {
@@ -65,42 +57,42 @@ function exportPart(db, subject) {
// ── Deletion ───────────────────────────────────────────────────
// ── Deletion ───────────────────────────────────────────────────
/** Ids of the rows in `table` authored by the subjects, split into those to delete and those with others' replies. */
/** Ids of the rows in `table` authored by the subjects, split into those to delete and those with others' replies. */
-function splitByReplies(db, table, subjects) {
- const mine = db.prepare(`SELECT id FROM ${table} WHERE author_subject IN ${inList(subjects)}`).all(...subjects).map((r) => r.id);
+async function splitByReplies(db, table, subjects) {
+ const mine = (await db.prepare(`SELECT id FROM ${table} WHERE author_subject IN ${inList(subjects)}`).all(...subjects)).map((r) => r.id);
const keep = [];
const keep = [];
const drop = [];
const drop = [];
for (const id of mine) {
for (const id of mine) {
- const other = db.prepare(`WITH RECURSIVE d(id) AS (SELECT id FROM ${table} WHERE parent_id = ? UNION ALL SELECT c.id FROM ${table} c JOIN d ON c.parent_id = d.id)
+ const other = await db.prepare(`WITH RECURSIVE d(id) AS (SELECT id FROM ${table} WHERE parent_id = ? UNION ALL SELECT c.id FROM ${table} c JOIN d ON c.parent_id = d.id)
SELECT 1 FROM ${table} WHERE id IN (SELECT id FROM d) AND (author_subject IS NULL OR author_subject NOT IN ${inList(subjects)}) LIMIT 1`).get(id, ...subjects);
SELECT 1 FROM ${table} WHERE id IN (SELECT id FROM d) AND (author_subject IS NULL OR author_subject NOT IN ${inList(subjects)}) LIMIT 1`).get(id, ...subjects);
(other ? keep : drop).push(id);
(other ? keep : drop).push(id);
}
}
return { keep, drop };
return { keep, drop };
}
}
-function erase(db, subjects, { now = new Date().toISOString() } = {}) {
+async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
const erased = {};
const erased = {};
const retained = {};
const retained = {};
const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
const S = inList(subjects);
const S = inList(subjects);
- db.transaction(() => {
+ await db.tx(async () => {
// Likes, votes and reactions first, so the counts recomputed below see only what stays.
// Likes, votes and reactions first, so the counts recomputed below see only what stays.
const recount = [];
const recount = [];
for (const [table, item] of [['paste_likes', 'paste_id'], ['comment_votes', 'comment_id'], ['thread_votes', 'thread_id'], ['post_reactions', 'post_id']]) {
for (const [table, item] of [['paste_likes', 'paste_id'], ['comment_votes', 'comment_id'], ['thread_votes', 'thread_id'], ['post_reactions', 'post_id']]) {
- if (!hasTable(db, table)) continue;
- const items = db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects).map((r) => r.i);
- add(erased, table, db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects).changes);
+ if (!await hasTable(db, table)) continue;
+ const items = (await db.prepare(`SELECT DISTINCT ${item} AS i FROM ${table} WHERE subject_id IN ${S}`).all(...subjects)).map((r) => r.i);
+ add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);
recount.push([table, items]);
recount.push([table, items]);
}
}
// Pastes: gone, unless someone else commented.
// Pastes: gone, unless someone else commented.
- if (hasTable(db, 'pastes')) {
- for (const p of db.prepare(`SELECT id FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {
- const others = db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);
+ if (await hasTable(db, 'pastes')) {
+ for (const p of await db.prepare(`SELECT id FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {
+ const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);
if (others) {
if (others) {
- db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,
+ await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,
ai_summary = NULL, ai_tags = NULL, legacy_user_id = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
ai_summary = NULL, ai_tags = NULL, legacy_user_id = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
add(retained, 'tombstones', 1);
add(retained, 'tombstones', 1);
} else {
} else {
- db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
+ await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
add(erased, 'pastes', 1);
add(erased, 'pastes', 1);
}
}
}
}
@@ -108,50 +100,50 @@ function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -108,50 +100,50 @@ function erase(db, subjects, { now = new Date().toISOString() } = {}) {
// Comments on pastes and on threads (VODs, clips, pages): gone, unless someone else answered beneath.
// Comments on pastes and on threads (VODs, clips, pages): gone, unless someone else answered beneath.
for (const [table, tomb] of [['paste_comments', 'author_subject = NULL, anon_name = NULL, message = ?, is_deleted = 1, updated_at = ?'],
for (const [table, tomb] of [['paste_comments', 'author_subject = NULL, anon_name = NULL, message = ?, is_deleted = 1, updated_at = ?'],
['comments', 'author_subject = NULL, anon_name = NULL, message = ?, deleted_at = COALESCE(deleted_at, ?)']]) {
['comments', 'author_subject = NULL, anon_name = NULL, message = ?, deleted_at = COALESCE(deleted_at, ?)']]) {
- if (!hasTable(db, table)) continue;
- const { keep, drop } = splitByReplies(db, table, subjects);
- for (const id of keep) db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);
- for (const id of drop) db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
+ if (!await hasTable(db, table)) continue;
+ const { keep, drop } = await splitByReplies(db, table, subjects);
+ for (const id of keep) await db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);
+ for (const id of drop) await db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
add(erased, table, drop.length);
add(erased, table, drop.length);
add(retained, 'tombstones', keep.length);
add(retained, 'tombstones', keep.length);
- if (table === 'comments') db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id) WHERE reply_count != (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id)').run();
+ if (table === 'comments') await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id) WHERE reply_count != (SELECT COUNT(*) FROM comments c WHERE c.parent_id = comments.id)').run();
}
}
// Threads: gone with their posts, unless someone else posted; then the thread and its opening post are tombstones.
// Threads: gone with their posts, unless someone else posted; then the thread and its opening post are tombstones.
- if (hasTable(db, 'threads')) {
- for (const t of db.prepare(`SELECT id FROM threads WHERE author_subject IN ${S}`).all(...subjects)) {
- const others = db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(t.id, ...subjects);
- if (others) { db.prepare('UPDATE threads SET author_subject = NULL, title = ? WHERE id = ?').run(TOMBSTONE, t.id); add(retained, 'tombstones', 1); }
- else { db.prepare('DELETE FROM threads WHERE id = ?').run(t.id); add(erased, 'threads', 1); }
+ if (await hasTable(db, 'threads')) {
+ for (const t of await db.prepare(`SELECT id FROM threads WHERE author_subject IN ${S}`).all(...subjects)) {
+ const others = await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(t.id, ...subjects);
+ if (others) { await db.prepare('UPDATE threads SET author_subject = NULL, title = ? WHERE id = ?').run(TOMBSTONE, t.id); add(retained, 'tombstones', 1); }
+ else { await db.prepare('DELETE FROM threads WHERE id = ?').run(t.id); add(erased, 'threads', 1); }
}
}
}
}
- if (hasTable(db, 'posts')) {
- for (const p of db.prepare(`SELECT id, thread_id, is_opening FROM posts WHERE author_subject IN ${S}`).all(...subjects)) {
- const others = p.is_opening && db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND id != ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.thread_id, p.id, ...subjects);
- if (others) { db.prepare('UPDATE posts SET author_subject = NULL, relay_author = NULL, body_markdown = ?, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?').run(TOMBSTONE, now, now, p.id); add(retained, 'tombstones', 1); }
- else { db.prepare('DELETE FROM posts WHERE id = ?').run(p.id); add(erased, 'posts', 1); }
+ if (await hasTable(db, 'posts')) {
+ for (const p of await db.prepare(`SELECT id, thread_id, is_opening FROM posts WHERE author_subject IN ${S}`).all(...subjects)) {
+ const others = p.is_opening && await db.prepare(`SELECT 1 FROM posts WHERE thread_id = ? AND id != ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.thread_id, p.id, ...subjects);
+ if (others) { await db.prepare('UPDATE posts SET author_subject = NULL, relay_author = NULL, body_markdown = ?, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?').run(TOMBSTONE, now, now, p.id); add(retained, 'tombstones', 1); }
+ else { await db.prepare('DELETE FROM posts WHERE id = ?').run(p.id); add(erased, 'posts', 1); }
}
}
- if (hasColumn(db, 'threads', 'reply_count')) db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
+ if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
}
}
- if (hasTable(db, 'attachments')) add(erased, 'attachments', db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects).changes);
+ if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);
for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {
['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks']]) {
- if (!hasTable(db, table)) continue;
+ if (!await hasTable(db, table)) continue;
const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;
const params = where.includes(' OR ') ? [...subjects, ...subjects] : subjects;
- add(erased, key, db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes);
+ add(erased, key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes);
}
}
- for (const table of ['spaces', 'comment_threads']) if (hasColumn(db, table, 'created_by')) db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);
+ for (const table of ['spaces', 'comment_threads']) if (await hasColumn(db, table, 'created_by')) await db.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by IN ${S}`).run(...subjects);
// The cached counts and scores follow the rows that stay.
// The cached counts and scores follow the rows that stay.
for (const [table, items] of recount) {
for (const [table, items] of recount) {
for (const i of items) {
for (const i of items) {
- if (table === 'paste_likes') db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(i, i);
+ if (table === 'paste_likes') await db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(i, i);
else if (table === 'comment_votes') {
else if (table === 'comment_votes') {
- const a = db.prepare('SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS u, COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS d FROM comment_votes WHERE comment_id = ?').get(i);
- db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, i);
- } else if (table === 'thread_votes') db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0) FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(i, i);
+ const a = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s, COUNT(*) FILTER (WHERE value = 1) AS u, COUNT(*) FILTER (WHERE value = -1) AS d FROM comment_votes WHERE comment_id = ?').get(i);
+ await db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, i);
+ } else if (table === 'thread_votes') await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(i, i);
}
}
}
}
- })();
+ });
return { erased, retained };
return { erased, retained };
}
}
@@ -162,7 +154,7 @@ function createSender({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -162,7 +154,7 @@ function createSender({ config, fetchImpl = globalThis.fetch } = {}) {
const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.network', fetchImpl });
const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.network', fetchImpl });
return async function send(path, body, retried = false) {
return async function send(path, body, retried = false) {
const res = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
const res = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
- if (res.status === 401 && !retried) { tokens.invalidate(); return send(path, body, true); }
+ if (res.status === 401 && !retried) { tokens.invalidate(); return await send(path, body, true); }
return res;
return res;
};
};
}
}
@@ -172,36 +164,36 @@ async function apply(db, ev, { send, log = console } = {}) {
@@ -172,36 +164,36 @@ async function apply(db, ev, { send, log = console } = {}) {
if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';
if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';
if (ev.source !== 'network') return 'ignored:source';
if (ev.source !== 'network') return 'ignored:source';
const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
- ensureSchema(db);
if (ev.event_type === 'network.account.export_requested') {
if (ev.event_type === 'network.account.export_requested') {
if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
- const seen = db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);
+ const seen = await db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);
if (seen && seen.sent_at) return 'unchanged';
if (seen && seen.sent_at) return 'unchanged';
- const part = exportPart(db, p.subject);
+ const part = await exportPart(db, p.subject);
const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });
const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });
const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
if (!outcome) throw new Error(`export part refused: ${res.status}`);
if (!outcome) throw new Error(`export part refused: ${res.status}`);
- db.prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')
+ await db.prepare(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject, outcome = excluded.outcome, sent_at = excluded.sent_at, applied_at = ov_now_iso()`)
.run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());
.run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());
return outcome;
return outcome;
}
}
if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
- let rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
+ let rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
let result = 'confirmed';
let result = 'confirmed';
if (!rec) {
if (!rec) {
const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
- const counts = erase(db, subjects);
- db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));
+ const counts = await erase(db, subjects);
+ await db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));
log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);
log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);
- rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
+ rec = await db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
result = 'erased';
result = 'erased';
}
}
if (rec.sent_at) return 'unchanged';
if (rec.sent_at) return 'unchanged';
const o = JSON.parse(rec.outcome || '{}');
const o = JSON.parse(rec.outcome || '{}');
const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });
const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });
if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);
if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);
- db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);
+ await db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);
return result;
return result;
}
}
-module.exports = { apply, exportPart, erase, ensureSchema, createSender, TOPICS };
+module.exports = { apply, exportPart, erase, createSender, TOPICS };
@@ -14,8 +14,8 @@ function createAuthors({ db, network = null }) {
@@ -14,8 +14,8 @@ function createAuthors({ db, network = null }) {
async function projectionsFor(subjects) {
async function projectionsFor(subjects) {
const list = (subjects || []).filter(Boolean);
const list = (subjects || []).filter(Boolean);
if (!list.length) return new Map();
if (!list.length) return new Map();
- if (!network) return pasteStore.getProjections(db, list);
- try { return await network.projections(list); } catch { return pasteStore.getProjections(db, list); }
+ if (!network) return await pasteStore.getProjections(db, list);
+ try { return await network.projections(list); } catch { return await pasteStore.getProjections(db, list); }
}
}
/**
/**
@@ -15,19 +15,6 @@ const { fail } = require('../http/v1');
@@ -15,19 +15,6 @@ const { fail } = require('../http/v1');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const ready = new WeakSet();
const ready = new WeakSet();
-function ensureSchema(db) {
- if (ready.has(db)) return;
- db.exec(`CREATE TABLE IF NOT EXISTS network_blocks (
- blocker_subject TEXT NOT NULL,
- blocked_subject TEXT NOT NULL,
- active INTEGER NOT NULL,
- revision INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- PRIMARY KEY (blocker_subject, blocked_subject)
- );
- CREATE INDEX IF NOT EXISTS idx_network_blocks_blocked ON network_blocks(blocked_subject, active);`);
- ready.add(db);
-}
/** The payload of a network.block.changed envelope from Network, or an 'ignored:*' reason. */
/** The payload of a network.block.changed envelope from Network, or an 'ignored:*' reason. */
function payloadOf(event) {
function payloadOf(event) {
@@ -38,9 +25,8 @@ function payloadOf(event) {
@@ -38,9 +25,8 @@ function payloadOf(event) {
}
}
/** Apply one change when it is newer than what is kept for its pair. → 'blocks:blocked' | 'blocks:unblocked' | 'blocks:unchanged' */
/** Apply one change when it is newer than what is kept for its pair. → 'blocks:blocked' | 'blocks:unblocked' | 'blocks:unchanged' */
-function apply(db, p, now = Date.now()) {
- ensureSchema(db);
- const r = db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)
+async function apply(db, p, now = Date.now()) {
+ const r = await db.prepare(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at
ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at
WHERE excluded.revision > network_blocks.revision`).run(p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now);
WHERE excluded.revision > network_blocks.revision`).run(p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now);
if (!r.changes) return 'blocks:unchanged';
if (!r.changes) return 'blocks:unchanged';
@@ -48,21 +34,20 @@ function apply(db, p, now = Date.now()) {
@@ -48,21 +34,20 @@ function apply(db, p, now = Date.now()) {
}
}
/** Did `blocker` block `blocked` (subjects)? */
/** Did `blocker` block `blocked` (subjects)? */
-function hasBlocked(db, blocker, blocked) {
+async function hasBlocked(db, blocker, blocked) {
if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || '')) || blocker === blocked) return false;
if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || '')) || blocker === blocked) return false;
- ensureSchema(db);
- return !!db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);
+ return !!await db.prepare('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(blocker, blocked);
}
}
/**
/**
* Refuse (403 community.blocked) when any of `owners` blocked `writer`. `what` names the thing in the
* Refuse (403 community.blocked) when any of `owners` blocked `writer`. `what` names the thing in the
* message: "You cannot <what>: its author blocked you".
* message: "You cannot <what>: its author blocked you".
*/
*/
-function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {
+async function refuseIfBlocked(db, owners, writer, what, whose = 'its author') {
if (!writer) return;
if (!writer) return;
for (const owner of owners) {
for (const owner of owners) {
- if (hasBlocked(db, owner, writer)) fail(403, 'community.blocked', `You cannot ${what}: ${whose} blocked you`);
+ if (await hasBlocked(db, owner, writer)) fail(403, 'community.blocked', `You cannot ${what}: ${whose} blocked you`);
}
}
}
}
-module.exports = { ensureSchema, payloadOf, apply, hasBlocked, refuseIfBlocked };
+module.exports = { payloadOf, apply, hasBlocked, refuseIfBlocked };
@@ -42,7 +42,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -42,7 +42,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
signal: AbortSignal.timeout(8000),
signal: AbortSignal.timeout(8000),
});
});
// A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.
// A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.
- if (res.status === 401 && !retried) { tokens.invalidate(); return post(body, true); }
+ if (res.status === 401 && !retried) { tokens.invalidate(); return await post(body, true); }
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok || !data || typeof data.results !== 'object') {
if (!res.ok || !data || typeof data.results !== 'object') {
const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
@@ -65,9 +65,9 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -65,9 +65,9 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
}
}
/** Store a Network projection in the cache. */
/** Store a Network projection in the cache. */
- function remember(p) {
+ async function remember(p) {
if (!p || !p.subject || !p.subject.id) return;
if (!p || !p.subject || !p.subject.id) return;
- store.upsertProjection(db, {
+ await store.upsertProjection(db, {
subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,
subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,
avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,
avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,
});
});
@@ -76,14 +76,14 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -76,14 +76,14 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
/** Subject ids → projections (and cache them). */
/** Subject ids → projections (and cache them). */
async function resolveSubjects(subjectIds) {
async function resolveSubjects(subjectIds) {
const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));
const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));
- for (const p of map.values()) remember(p);
+ for (const p of map.values()) await remember(p);
return map;
return map;
}
}
/** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */
/** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */
async function resolveLegacy(system, ids, { cache = true } = {}) {
async function resolveLegacy(system, ids, { cache = true } = {}) {
const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));
const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));
- if (cache) for (const p of map.values()) remember(p);
+ if (cache) for (const p of map.values()) await remember(p);
return map;
return map;
}
}
@@ -93,19 +93,19 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -93,19 +93,19 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
*/
*/
async function subjectForNetworkUser(networkUserId) {
async function subjectForNetworkUser(networkUserId) {
if (networkUserId == null || networkUserId === '') return null;
if (networkUserId == null || networkUserId === '') return null;
- const hit = store.mapGet(db, 'network', 'user', networkUserId);
+ const hit = await store.mapGet(db, 'network', 'user', networkUserId);
if (hit && hit.target_type === 'subject') return hit.target_id;
if (hit && hit.target_type === 'subject') return hit.target_id;
const map = await resolveLegacy('network', [networkUserId]);
const map = await resolveLegacy('network', [networkUserId]);
const p = map.get(String(networkUserId));
const p = map.get(String(networkUserId));
if (!p || !p.subject || p.subject.type !== 'user') return null;
if (!p || !p.subject || p.subject.type !== 'user') return null;
- store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);
+ await store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);
return p.subject.id;
return p.subject.id;
}
}
/** What a verified user JWT says about its holder is a fresh projection too. */
/** What a verified user JWT says about its holder is a fresh projection too. */
- function rememberClaims(subjectId, claims) {
+ async function rememberClaims(subjectId, claims) {
if (!subjectId || !claims) return;
if (!subjectId || !claims) return;
- store.upsertProjection(db, {
+ await store.upsertProjection(db, {
subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,
subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,
avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,
avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,
});
});
@@ -133,7 +133,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -133,7 +133,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
async function projections(subjectIds) {
async function projections(subjectIds) {
const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];
const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];
if (!ids.length) return new Map();
if (!ids.length) return new Map();
- let map = store.getProjections(db, ids);
+ let map = await store.getProjections(db, ids);
const missing = ids.filter((id) => !map.has(id));
const missing = ids.filter((id) => !map.has(id));
const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);
const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);
if (stale.length) refreshLater(stale);
if (stale.length) refreshLater(stale);
@@ -144,7 +144,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -144,7 +144,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));
const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));
await Promise.race([lookup, wait]);
await Promise.race([lookup, wait]);
clearTimeout(timer);
clearTimeout(timer);
- map = store.getProjections(db, ids);
+ map = await store.getProjections(db, ids);
}
}
return map;
return map;
}
}
@@ -24,21 +24,14 @@ const toIso = (v) => {
@@ -24,21 +24,14 @@ const toIso = (v) => {
};
};
const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
-function ensureSchema(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS profile_module_pushes (
- subject_id TEXT PRIMARY KEY,
- hash TEXT NOT NULL,
- pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP
- )`);
-}
/** The record for one person, or null when they wrote nothing. */
/** The record for one person, or null when they wrote nothing. */
-function summarize(db, subject) {
- const one = (sql) => db.prepare(sql).get(subject);
- const threads = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM threads WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");
- const posts = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM posts WHERE author_subject = ? AND deleted_at IS NULL AND is_opening = 0 AND origin = 'user'");
- const comments = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM comments WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");
- const pastes = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM pastes WHERE owner_subject = ? AND deleted_at IS NULL AND origin = 'user' AND visibility = 'public'");
+async function summarize(db, subject) {
+ const one = async (sql) => await db.prepare(sql).get(subject);
+ const threads = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM threads WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");
+ const posts = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM posts WHERE author_subject = ? AND deleted_at IS NULL AND is_opening = 0 AND origin = 'user'");
+ const comments = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM comments WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");
+ const pastes = await one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM pastes WHERE owner_subject = ? AND deleted_at IS NULL AND origin = 'user' AND visibility = 'public'");
const all = [threads, posts, comments, pastes];
const all = [threads, posts, comments, pastes];
if (!all.some((r) => r.n)) return null;
if (!all.some((r) => r.n)) return null;
const firsts = all.map((r) => r.first).filter(Boolean).sort();
const firsts = all.map((r) => r.first).filter(Boolean).sort();
@@ -70,13 +63,13 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
@@ -70,13 +63,13 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
/** Write one person's record when it changed. → true when written */
/** Write one person's record when it changed. → true when written */
async function push(subject) {
async function push(subject) {
if (!enabled || !USR.test(String(subject || ''))) return false;
if (!enabled || !USR.test(String(subject || ''))) return false;
- const data = summarize(db, subject);
+ const data = await summarize(db, subject);
if (!data) return false;
if (!data) return false;
const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
- const last = db.prepare('SELECT hash FROM profile_module_pushes WHERE subject_id = ?').get(subject);
+ const last = await db.prepare('SELECT hash FROM profile_module_pushes WHERE subject_id = ?').get(subject);
if (last && last.hash === hash) { stats.unchanged++; return false; }
if (last && last.hash === hash) { stats.unchanged++; return false; }
try { await client().put(NS, subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; return false; }
try { await client().put(NS, subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; return false; }
- db.prepare(`INSERT INTO profile_module_pushes (subject_id, hash, pushed_at) VALUES (?, ?, CURRENT_TIMESTAMP)
+ await db.prepare(`INSERT INTO profile_module_pushes (subject_id, hash, pushed_at) VALUES (?, ?, ov_now())
ON CONFLICT(subject_id) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash);
ON CONFLICT(subject_id) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash);
stats.written++;
stats.written++;
return true;
return true;
@@ -94,7 +87,7 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
@@ -94,7 +87,7 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
changed('comments', 'author_subject', ['created_at', 'updated_at', 'deleted_at']),
changed('comments', 'author_subject', ['created_at', 'updated_at', 'deleted_at']),
changed('pastes', 'owner_subject', ['created_at', 'updated_at', 'deleted_at']),
changed('pastes', 'owner_subject', ['created_at', 'updated_at', 'deleted_at']),
].join(' UNION ');
].join(' UNION ');
- const subjects = db.prepare(sql).all({ from, to }).map((r) => r.s).filter((s) => USR.test(String(s || '')));
+ const subjects = (await db.prepare(sql).all({ from, to })).map((r) => r.s).filter((s) => USR.test(String(s || '')));
for (const s of subjects) { if (stopped) return subjects.length; await push(s); }
for (const s of subjects) { if (stopped) return subjects.length; await push(s); }
lastScan = to;
lastScan = to;
return subjects.length;
return subjects.length;
@@ -105,7 +98,6 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
@@ -105,7 +98,6 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
function start() {
function start() {
if (!enabled || timer) return false;
if (!enabled || timer) return false;
stopped = false;
stopped = false;
- ensureSchema(db);
timer = setInterval(() => {
timer = setInterval(() => {
if (running) return;
if (running) return;
running = scan().catch((err) => { stats.lastError = err.message; }).finally(() => { running = null; });
running = scan().catch((err) => { stats.lastError = err.message; }).finally(() => { running = null; });
@@ -120,4 +112,4 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
@@ -120,4 +112,4 @@ function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = c
return { enabled, push, scan, start, stop, stats: () => ({ enabled, ...stats }) };
return { enabled, push, scan, start, stop, stats: () => ({ enabled, ...stats }) };
}
}
-module.exports = { NS, ensureSchema, summarize, createProfileModule };
+module.exports = { NS, summarize, createProfileModule };
@@ -17,16 +17,16 @@ const AUTHORSHIP = [
@@ -17,16 +17,16 @@ const AUTHORSHIP = [
];
];
// Per person per item: [table, item column, recompute(db, itemId)]
// Per person per item: [table, item column, recompute(db, itemId)]
const PER_ITEM = [
const PER_ITEM = [
- ['paste_likes', 'paste_id', (db, id) => db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(id, id)],
- ['comment_votes', 'comment_id', (db, id) => {
- const a = db.prepare('SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS u, COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS d FROM comment_votes WHERE comment_id = ?').get(id);
- db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, id);
+ ['paste_likes', 'paste_id', async (db, id) => await db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(id, id)],
+ ['comment_votes', 'comment_id', async (db, id) => {
+ const a = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s, COUNT(*) FILTER (WHERE value = 1) AS u, COUNT(*) FILTER (WHERE value = -1) AS d FROM comment_votes WHERE comment_id = ?').get(id);
+ await db.prepare('UPDATE comments SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?').run(a.s, a.u, a.d, id);
}],
}],
- ['thread_votes', 'thread_id', (db, id) => db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0) FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(id, id)],
+ ['thread_votes', 'thread_id', async (db, id) => await db.prepare('UPDATE threads SET score = (SELECT COALESCE(SUM(value), 0)::bigint FROM thread_votes WHERE thread_id = ?) WHERE id = ?').run(id, id)],
['post_reactions', 'post_id', null],
['post_reactions', 'post_id', null],
];
];
-const hasColumn = (db, table, col) => { try { return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col); } catch { return false; } };
+const hasColumn = async (db, table, col) => !!await db.prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?').get(table, col);
/** The payload, or 'ignored:<why>'. */
/** The payload, or 'ignored:<why>'. */
function payloadOf(event) {
function payloadOf(event) {
@@ -38,35 +38,35 @@ function payloadOf(event) {
@@ -38,35 +38,35 @@ function payloadOf(event) {
}
}
/** Apply one merge → 'merge:applied' (with counts logged). */
/** Apply one merge → 'merge:applied' (with counts logged). */
-function apply(db, { from, into, merge_id: mergeId }, { log = console } = {}) {
- const counts = db.transaction(() => {
+async function apply(db, { from, into, merge_id: mergeId }, { log = console } = {}) {
+ const counts = await db.tx(async () => {
const c = { authored: 0, moved: 0, dropped: 0 };
const c = { authored: 0, moved: 0, dropped: 0 };
for (const [table, col] of AUTHORSHIP) {
for (const [table, col] of AUTHORSHIP) {
- if (hasColumn(db, table, col)) c.authored += db.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`).run(into, from).changes;
+ if (await hasColumn(db, table, col)) c.authored += (await db.prepare(`UPDATE ${table} SET ${col} = ? WHERE ${col} = ?`).run(into, from)).changes;
}
}
for (const [table, item, recompute] of PER_ITEM) {
for (const [table, item, recompute] of PER_ITEM) {
- if (!hasColumn(db, table, 'subject_id')) continue;
- for (const r of db.prepare(`SELECT ${item} AS i FROM ${table} WHERE subject_id = ?`).all(from)) {
- const clash = db.prepare(`SELECT 1 FROM ${table} WHERE ${item} = ? AND subject_id = ?`).get(r.i, into);
- if (clash) { db.prepare(`DELETE FROM ${table} WHERE ${item} = ? AND subject_id = ?`).run(r.i, from); c.dropped++; if (recompute) recompute(db, r.i); }
- else { db.prepare(`UPDATE ${table} SET subject_id = ? WHERE ${item} = ? AND subject_id = ?`).run(into, r.i, from); c.moved++; }
+ if (!await hasColumn(db, table, 'subject_id')) continue;
+ for (const r of await db.prepare(`SELECT ${item} AS i FROM ${table} WHERE subject_id = ?`).all(from)) {
+ const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${item} = ? AND subject_id = ?`).get(r.i, into);
+ if (clash) { await db.prepare(`DELETE FROM ${table} WHERE ${item} = ? AND subject_id = ?`).run(r.i, from); c.dropped++; if (recompute) recompute(db, r.i); }
+ else { await db.prepare(`UPDATE ${table} SET subject_id = ? WHERE ${item} = ? AND subject_id = ?`).run(into, r.i, from); c.moved++; }
}
}
}
}
- if (hasColumn(db, 'game_progress', 'subject_id')) {
- if (db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);
- else db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);
+ if (await hasColumn(db, 'game_progress', 'subject_id')) {
+ if (await db.prepare('SELECT 1 FROM game_progress WHERE subject_id = ?').get(into)) await db.prepare('DELETE FROM game_progress WHERE subject_id = ?').run(from);
+ else await db.prepare('UPDATE game_progress SET subject_id = ? WHERE subject_id = ?').run(into, from);
}
}
- if (hasColumn(db, 'network_blocks', 'blocker_subject')) {
+ if (await hasColumn(db, 'network_blocks', 'blocker_subject')) {
for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
- for (const b of db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
- const clash = b.o === into || db.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
- if (clash) db.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
- else db.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
+ for (const b of await db.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
+ const clash = b.o === into || await db.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
+ if (clash) await db.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
+ else await db.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
}
}
}
}
}
}
return c;
return c;
- })();
+ });
log.log(`[Merge] ${mergeId}: ${JSON.stringify(counts)}`);
log.log(`[Merge] ${mergeId}: ${JSON.stringify(counts)}`);
return 'merge:applied';
return 'merge:applied';
}
}
@@ -112,7 +112,7 @@ function createViewerResolver({ auth, config, network, revocations = null }) {
@@ -112,7 +112,7 @@ function createViewerResolver({ auth, config, network, revocations = null }) {
if (!subject && network && claims.sub != null) {
if (!subject && network && claims.sub != null) {
try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
}
}
- if (subject && network) { try { network.rememberClaims(subject, claims); } catch { /* display cache only */ } }
+ if (subject && network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }
const user = claimsToUser(claims);
const user = claimsToUser(claims);
if (subject && !user.subject_id) user.subject_id = subject;
if (subject && !user.subject_id) user.subject_id = subject;
// Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.
// Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.
@@ -132,7 +132,7 @@ function createViewerResolver({ auth, config, network, revocations = null }) {
@@ -132,7 +132,7 @@ function createViewerResolver({ auth, config, network, revocations = null }) {
const payload = decodeJwtPayload(token);
const payload = decodeJwtPayload(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);
}
}
}
}
const token = extractToken(req);
const token = extractToken(req);
@@ -7,69 +7,77 @@
@@ -7,69 +7,77 @@
const config = require('./config');
const config = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
-const app = createApp();
-if (config.discordRelay.enabled) {
- const rs = app.locals.relay.status();
- const part = (x) => (x.enabled ? 'on' : `off (${x.reason})`);
- console.log(`[Relay] Discord relay on: creates queued by the ${rs.creates_from === 'events' ? 'Events worker' : 'forum'}; events worker ${part(rs.events_worker)}; inbound ${part(rs.inbound)}`);
-}
-// Community → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.
-try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }
-// The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).
-try {
- const r = require('./forum/roadmap').syncFromFile(require('./db').getDb());
- if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);
-} catch (err) { console.warn('[Roadmap] not synced:', err.message); }
-const server = app.listen(config.port, config.host, () => {
- console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
- console.log(config.pastesAuthority === 'community'
- ? `[Community] pastes: this site is the authority (${config.dbPath}), identity via ${config.networkUrl}`
- : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
-});
-server.keepAliveTimeout = 65_000;
-// Subscribe Pulse to public activity at Events (idempotent; off without EVENTS_URL / COMMUNITY_EVENTS_SECRET).
-let subscriptions = null;
-try {
- const secret = String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',')[0].trim();
- subscriptions = require('./pulse/consumer').startSubscriptions({ config, port: config.port, secret });
-} catch (err) { console.warn('[Pulse consumer] not subscribed:', err.message); }
-// community.profile on Network (Contracts 0.41.0): a 5-minute scan of changed authors (off without the client secret).
-let profiles = null;
-try { profiles = require('./identity/profile-module').createProfileModule({ db: require('./db').getDb(), config }); profiles.start(); } catch (err) { console.warn('[Modules] community.profile not started:', err.message); }
-// Public threads and pastes in OpenVibe.Search (WS-O task 10): community.index_document.* through the outbox (off without EVENTS_URL).
-let searchDocs = null;
-try { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }
+(async () => {
+ // PostgreSQL first (migrations run as the owner), then the app: its revocation cutoffs load before it serves.
+ await require('./db').initDb(config);
+ const app = await createApp();
+ if (config.discordRelay.enabled) {
+ const rs = await app.locals.relay.status();
+ const part = (x) => (x.enabled ? 'on' : `off (${x.reason})`);
+ console.log(`[Relay] Discord relay on: creates queued by the ${rs.creates_from === 'events' ? 'Events worker' : 'forum'}; events worker ${part(rs.events_worker)}; inbound ${part(rs.inbound)}`);
+ }
+ // Community → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.
+ try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }
+ // The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).
+ try {
+ const r = await require('./forum/roadmap').syncFromFile(require('./db').getDb());
+ if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);
+ } catch (err) { console.warn('[Roadmap] not synced:', err.message); }
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
+ console.log(config.pastesAuthority === 'community'
+ ? `[Community] pastes: this site is the authority (${config.dbPath}), identity via ${config.networkUrl}`
+ : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
+ });
+ server.keepAliveTimeout = 65_000;
+ // Subscribe Pulse to public activity at Events (idempotent; off without EVENTS_URL / COMMUNITY_EVENTS_SECRET).
+ let subscriptions = null;
+ try {
+ const secret = String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',')[0].trim();
+ subscriptions = require('./pulse/consumer').startSubscriptions({ config, port: config.port, secret });
+ } catch (err) { console.warn('[Pulse consumer] not subscribed:', err.message); }
+ // community.profile on Network (Contracts 0.41.0): a 5-minute scan of changed authors (off without the client secret).
+ let profiles = null;
+ try { profiles = require('./identity/profile-module').createProfileModule({ db: require('./db').getDb(), config }); profiles.start(); } catch (err) { console.warn('[Modules] community.profile not started:', err.message); }
+ // Public threads and pastes in OpenVibe.Search (WS-O task 10): community.index_document.* through the outbox (off without EVENTS_URL).
+ let searchDocs = null;
+ try { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }
-// ── Stop (roadmap WS-P lifecycle; server/graceful.js) ────────
-// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
-// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps
-// them 65 s otherwise) and lets requests in flight finish (4 s at most); then the profile scan in
-// progress, the Discord relay's drain and its Events worker's page (the gateway closes at once) and the
-// events outbox's send finish (unsent rows stay in their tables for the next start), community.db
-// closes, and the process exits 0, within the manifest's 5 s.
-const { within } = require('./graceful');
-let profilesDone = null;
-require('./graceful').gracefulStop({
- name: 'Community', server,
- stop: [
- () => { if (subscriptions) subscriptions.stop(); },
- () => { if (profiles) profilesDone = profiles.stop(); },
- () => { if (searchDocs) searchDocs.stop(); },
- ],
- close: [
- () => within(1000, profilesDone),
- () => within(1000, app.locals.relay && app.locals.relay.stop()),
- () => within(1500, require('./events').stop()),
- () => {
- const ev = require('./events').status();
- let relay = 'off';
- if (config.discordRelay.enabled) {
- let rs = null;
- try { rs = app.locals.relay.status(); } catch { /* the database is gone */ }
- relay = rs ? `stopped (events worker ${rs.events_worker.enabled ? 'stopped' : 'off'}, gateway ${rs.inbound.enabled ? 'stopped' : 'off'})` : 'stopped';
- }
- console.log(`[Community] stopped: subscriptions ${subscriptions ? 'stopped' : 'off'}, profile scan ${profiles && profiles.enabled ? 'stopped' : 'off'}, search scans ${searchDocs ? 'stopped' : 'off'}, relay ${relay}, outbox ${ev.enabled ? `stopped (${ev.pending} pending)` : 'off'}`);
- require('./db').closeDb();
- },
- ],
+ // ── Stop (roadmap WS-P lifecycle; server/graceful.js) ────────
+ // SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
+ // stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps
+ // them 65 s otherwise) and lets requests in flight finish (4 s at most); then the profile scan in
+ // progress, the Discord relay's drain and its Events worker's page (the gateway closes at once) and the
+ // events outbox's send finish (unsent rows stay in their tables for the next start), community.db
+ // closes, and the process exits 0, within the manifest's 5 s.
+ const { within } = require('./graceful');
+ let profilesDone = null;
+ require('./graceful').gracefulStop({
+ name: 'Community', server,
+ stop: [
+ () => { if (subscriptions) subscriptions.stop(); },
+ () => { if (profiles) profilesDone = profiles.stop(); },
+ () => { if (searchDocs) searchDocs.stop(); },
+ ],
+ close: [
+ () => within(1000, profilesDone),
+ () => within(1000, app.locals.relay && app.locals.relay.stop()),
+ () => within(1500, require('./events').stop()),
+ async () => {
+ const ev = await require('./events').backlog().catch(() => require('./events').status());
+ let relay = 'off';
+ if (config.discordRelay.enabled) {
+ let rs = null;
+ try { rs = await app.locals.relay.status(); } catch { /* the database is gone */ }
+ relay = rs ? `stopped (events worker ${rs.events_worker.enabled ? 'stopped' : 'off'}, gateway ${rs.inbound.enabled ? 'stopped' : 'off'})` : 'stopped';
+ }
+ console.log(`[Community] stopped: subscriptions ${subscriptions ? 'stopped' : 'off'}, profile scan ${profiles && profiles.enabled ? 'stopped' : 'off'}, search scans ${searchDocs ? 'stopped' : 'off'}, relay ${relay}, outbox ${ev.enabled ? `stopped (${ev.pending} pending)` : 'off'}`);
+ if (app.locals.valkey) app.locals.valkey.close().catch(() => {});
+ return await require('./db').closeDb();
+ },
+ ],
+ });
+})().catch((err) => {
+ console.error('[Community] failed to start:', err);
+ process.exit(1);
});
});
@@ -60,8 +60,8 @@ async function request(apiPath, { method = 'GET', query, body, token, ip, timeou
@@ -60,8 +60,8 @@ async function request(apiPath, { method = 'GET', query, body, token, ip, timeou
// ── Pastes ───────────────────────────────────────────────────
// ── Pastes ───────────────────────────────────────────────────
/** Public listing: { pastes, total, limit, offset }. */
/** Public listing: { pastes, total, limit, offset }. */
-function listPastes(query, ctx = {}) {
- return request('/pastes', { query, token: ctx.token, ip: ctx.ip });
+async function listPastes(query, ctx = {}) {
+ return await request('/pastes', { query, token: ctx.token, ip: ctx.ip });
}
}
/** One paste (counts a view unless noView). Throws LiveApiError(404) when unknown/private. */
/** One paste (counts a view unless noView). Throws LiveApiError(404) when unknown/private. */
@@ -73,13 +73,13 @@ async function getPaste(slug, ctx = {}) {
@@ -73,13 +73,13 @@ async function getPaste(slug, ctx = {}) {
}
}
/** A user's pastes ({ pastes, total, username }); the owner also gets unlisted/private ones. */
/** A user's pastes ({ pastes, total, username }); the owner also gets unlisted/private ones. */
-function listByUser(username, query, ctx = {}) {
- return request(`/pastes/by-user/${encodeURIComponent(username)}`, { query, token: ctx.token, ip: ctx.ip });
+async function listByUser(username, query, ctx = {}) {
+ return await request(`/pastes/by-user/${encodeURIComponent(username)}`, { query, token: ctx.token, ip: ctx.ip });
}
}
/** Create a text paste on the visitor's behalf (no-JS form fallback). */
/** Create a text paste on the visitor's behalf (no-JS form fallback). */
-function createPaste(body, ctx = {}) {
- return request('/pastes', { method: 'POST', body, token: ctx.token, ip: ctx.ip, timeoutMs: 20_000 });
+async function createPaste(body, ctx = {}) {
+ return await request('/pastes', { method: 'POST', body, token: ctx.token, ip: ctx.ip, timeoutMs: 20_000 });
}
}
// ── Public URL builders (OpenVibe.Media serves the bytes) ────
// ── Public URL builders (OpenVibe.Media serves the bytes) ────
@@ -32,7 +32,7 @@ function createMediaFiles({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -32,7 +32,7 @@ function createMediaFiles({ config, fetchImpl = globalThis.fetch } = {}) {
body: fd,
body: fd,
signal: AbortSignal.timeout(60_000),
signal: AbortSignal.timeout(60_000),
});
});
- if (res.status === 401 && !retried) { tokens.invalidate(); return upload({ buffer, filename, mime }, true); }
+ if (res.status === 401 && !retried) { tokens.invalidate(); return await upload({ buffer, filename, mime }, true); }
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok || !data || !data.key || !data.url) {
if (!res.ok || !data || !data.key || !data.url) {
const err = new Error(`Media upload ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
const err = new Error(`Media upload ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
@@ -31,7 +31,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -31,7 +31,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };
const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };
if (json !== undefined) h['Content-Type'] = 'application/json';
if (json !== undefined) h['Content-Type'] = 'application/json';
const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });
const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });
- if (res.status === 401 && !retried) { tokens.invalidate(); return call(method, path, { json, body, headers }, true); }
+ if (res.status === 401 && !retried) { tokens.invalidate(); return await call(method, path, { json, body, headers }, true); }
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!res.ok) {
const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);
const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);
@@ -26,15 +26,19 @@ function probe(url, fetchImpl) {
@@ -26,15 +26,19 @@ function probe(url, fetchImpl) {
};
};
}
}
-function createCommunityReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch }) {
+function createCommunityReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch, valkey = null }) {
const checks = [
const checks = [
{
{
name: 'db', required: true,
name: 'db', required: true,
- check: () => {
- const n = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;
- return n > 0 || 'database has no tables';
+ // A real round trip that names the store (postgresql / pglite), and a migrated schema.
+ 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()').get()).n;
+ return n > 0 ? { ok: true, detail: r.detail } : 'database has no tables (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,
name: 'network_jwks', required: false,
check: () => {
check: () => {
@@ -54,11 +58,11 @@ function createCommunityReadiness({ db, auth, config, relay = null, release = nu
@@ -54,11 +58,11 @@ function createCommunityReadiness({ db, auth, config, relay = null, release = nu
service: 'community',
service: 'community',
release,
release,
checks,
checks,
- details: (body) => {
+ details: async (body) => {
const out = { pastes_authority: config.pastesAuthority };
const out = { pastes_authority: config.pastesAuthority };
if (relay && relay.enabled && body.checks.db.status === 'ok') {
if (relay && relay.enabled && body.checks.db.status === 'ok') {
// The queue by status (failed = dead letters), and whether the Events worker and the inbound gateway run.
// The queue by status (failed = dead letters), and whether the Events worker and the inbound gateway run.
- const st = relay.status();
+ const st = await relay.status();
const brief = (x) => (x.enabled ? { enabled: true, state: x.state || (x.running ? 'running' : 'stopped'), last_error: x.last_error || null, ...(x.lag != null ? { lag: x.lag } : {}) } : { enabled: false, reason: x.reason });
const brief = (x) => (x.enabled ? { enabled: true, state: x.state || (x.running ? 'running' : 'stopped'), last_error: x.last_error || null, ...(x.lag != null ? { lag: x.lag } : {}) } : { enabled: false, reason: x.reason });
out.discord_relay = { enabled: true, deliveries: st.deliveries, creates_from: st.creates_from, events_worker: brief(st.events_worker), inbound: brief(st.inbound), inbound_failures: st.inbound_failures };
out.discord_relay = { enabled: true, deliveries: st.deliveries, creates_from: st.creates_from, events_worker: brief(st.events_worker), inbound: brief(st.inbound), inbound_failures: st.inbound_failures };
}
}
@@ -110,45 +110,45 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
@@ -110,45 +110,45 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
// ── collection + literal paths (before /:slug) ───────────
// ── collection + literal paths (before /:slug) ───────────
router.get('/', anyCap, (req, res, next) => (req.query.needs_ai && req.viewer.kind !== 'service'
router.get('/', anyCap, (req, res, next) => (req.query.needs_ai && req.viewer.kind !== 'service'
? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })
? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })
- : next()), run((req) => service.list(req.viewer, req.query)));
+ : next()), run(async (req) => await service.list(req.viewer, req.query)));
router.post('/', guard('community.paste.create'), limits('community.paste.create', { minute: 30, hour: 600 }), anonWriteLimiter, body,
router.post('/', guard('community.paste.create'), limits('community.paste.create', { minute: 30, hour: 600 }), anonWriteLimiter, body,
- run((req) => (req.file ? service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : service.createText(req.viewer, req.body || {})), 201));
+ run(async (req) => (req.file ? await service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : await service.createText(req.viewer, req.body || {})), 201));
- router.get('/config', anyCap, run((req) => service.config(req.viewer)));
+ router.get('/config', anyCap, run(async (req) => await service.config(req.viewer)));
- router.get('/admin/stats', staffOnly, run(() => service.stats()));
- router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
- router.delete('/admin/forks', staffOnly, staffWrite, run((req) => service.deleteForks(req.viewer)));
- router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run((req) => service.bulk(req.body || {}, req.viewer)));
+ router.get('/admin/stats', staffOnly, run(async () => await service.stats()));
+ router.get('/admin/forks', staffOnly, run(async (req) => await service.forks(req.query)));
+ router.delete('/admin/forks', staffOnly, staffWrite, run(async (req) => await service.deleteForks(req.viewer)));
+ router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run(async (req) => await service.bulk(req.body || {}, req.viewer)));
// An image paste stores its file in OpenVibe.Media.
// An image paste stores its file in OpenVibe.Media.
router.post('/screenshot', guard('community.paste.create'), limits('community.paste.screenshot', { minute: 20, hour: 300 }), anonWriteLimiter, withFile,
router.post('/screenshot', guard('community.paste.create'), limits('community.paste.screenshot', { minute: 20, hour: 300 }), anonWriteLimiter, withFile,
- run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
+ run(async (req) => await service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
- router.get('/by-user/:username', anyCap, run((req) => service.byUser(req.viewer, req.params.username, req.query)));
+ router.get('/by-user/:username', anyCap, run(async (req) => await service.byUser(req.viewer, req.params.username, req.query)));
// ── one paste ────────────────────────────────────────────
// ── one paste ────────────────────────────────────────────
- router.get('/:slug', anyCap, run((req) => service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));
- router.put('/:slug', guard('community.paste.write'), limits('community.paste.edit', { minute: 30, hour: 300 }), json, jsonErrors, run((req) => service.update(req.viewer, slug(req), req.body || {})));
- router.delete('/:slug', guard('community.paste.write'), limits('community.paste.delete', { minute: 60, hour: 600 }), run((req) => service.remove(req.viewer, slug(req))));
+ router.get('/:slug', anyCap, run(async (req) => await service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));
+ router.put('/:slug', guard('community.paste.write'), limits('community.paste.edit', { minute: 30, hour: 300 }), json, jsonErrors, run(async (req) => await service.update(req.viewer, slug(req), req.body || {})));
+ router.delete('/:slug', guard('community.paste.write'), limits('community.paste.delete', { minute: 60, hour: 600 }), run(async (req) => await service.remove(req.viewer, slug(req))));
- router.post('/:slug/censor', staffOnly, limits('community.paste.censor', { minute: 20, hour: 200 }), withFile, run((req) => service.censor(req.viewer, slug(req), req.file)));
+ router.post('/:slug/censor', staffOnly, limits('community.paste.censor', { minute: 20, hour: 200 }), withFile, run(async (req) => await service.censor(req.viewer, slug(req), req.file)));
// Live's AI pass writes one paste's summary at a time, behind a model call.
// Live's AI pass writes one paste's summary at a time, behind a model call.
- router.post('/:slug/ai', serviceModerator, limits('community.paste.ai', { minute: 60, hour: 1200 }), json, jsonErrors, run((req) => service.setAi(req.viewer, slug(req), req.body || {})));
+ router.post('/:slug/ai', serviceModerator, limits('community.paste.ai', { minute: 60, hour: 1200 }), json, jsonErrors, run(async (req) => await service.setAi(req.viewer, slug(req), req.body || {})));
router.get('/:slug/raw', (req, res) => res.redirect(302, `/p/${encodeURIComponent(slug(req))}/raw`));
router.get('/:slug/raw', (req, res) => res.redirect(302, `/p/${encodeURIComponent(slug(req))}/raw`));
- router.get('/:slug/versions', anyCap, run((req) => service.versions(req.viewer, slug(req))));
+ router.get('/:slug/versions', anyCap, run(async (req) => await service.versions(req.viewer, slug(req))));
- router.post('/:slug/fork', guard('community.paste.write'), limits('community.paste.fork', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors, run((req) => service.fork(req.viewer, slug(req)), 201));
- router.post('/:slug/like', guard('community.paste.write'), react, run((req) => service.like(req.viewer, slug(req))));
- router.post('/:slug/copy', guard('community.paste.write'), react, run((req) => service.copy(req.viewer, slug(req))));
+ router.post('/:slug/fork', guard('community.paste.write'), limits('community.paste.fork', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors, run(async (req) => await service.fork(req.viewer, slug(req)), 201));
+ router.post('/:slug/like', guard('community.paste.write'), react, run(async (req) => await service.like(req.viewer, slug(req))));
+ router.post('/:slug/copy', guard('community.paste.write'), react, run(async (req) => await service.copy(req.viewer, slug(req))));
- router.get('/:slug/comments', anyCap, run((req) => service.comments(req.viewer, slug(req), req.query)));
+ router.get('/:slug/comments', anyCap, run(async (req) => await service.comments(req.viewer, slug(req), req.query)));
router.post('/:slug/comments', guard('community.paste.write'), limits('community.paste.comment', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors,
router.post('/:slug/comments', guard('community.paste.write'), limits('community.paste.comment', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors,
- run((req) => service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));
+ run(async (req) => await service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));
// Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.
// Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.
- router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run((req) => service.deleteComment(req.viewer, slug(req), req.params.commentId)));
+ router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run(async (req) => await service.deleteComment(req.viewer, slug(req), req.params.commentId)));
// Avatars belong to the Network account; Live's proxy set a Live-local avatar here.
// Avatars belong to the Network account; Live's proxy set a Live-local avatar here.
router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
deleted file mode 100644
deleted file mode 100644
@@ -1,326 +0,0 @@
@@ -1,326 +0,0 @@
-'use strict';
-
-/**
- * Paste importer — OpenVibe.Media's paste export bundle → Community's store.
- *
- * Bundle (produced by Media, format openvibe.media.pastes-export v1):
- * { format, version, app, generated_at, since_id, max_id, sha256, counts: {pastes, comments, likes},
- * pastes: [...], comments: [...], likes: [...] }
- * sha256 is over JSON.stringify({ pastes, comments, likes }).
- *
- * Rules:
- * - The bundle is verified (format, version, counts, sha256) before anything is touched.
- * - Idempotent: rows are upserted by legacy_media_id, so a re-run changes nothing and a newer
- * bundle adds and updates. A paste deleted in Community stays deleted.
- * - Slugs, timestamps, counters, visibility, NSFW and AI fields are kept. Counters never go
- * down (Community may have counted views since the last run).
- * - Owners: bundle user_ids are Live user ids, except rows created before the id-space fix
- * (cutoff) which may hold a Network user id instead. Every id is resolved through the Network
- * (system=live, and system=network for pre-cutoff rows). Pre-cutoff with two different
- * answers → imported ownerless + hold 'ambiguous_owner'; no answer → ownerless + hold
- * 'owner_unmapped'. A like that can't be mapped is held and not imported.
- * - AI moment pastes (Live's AI jobs) get origin 'ai' and no owner: AI output is never
- * attributed to a person. Their stream goes into stream_ref.
- * - Every mapping lands in legacy_id_map; every run in migration_runs with a reconciliation report.
- * - dryRun does all of it inside a transaction and rolls back.
- */
-const crypto = require('crypto');
-const { ids } = require('openvibe-contracts');
-const store = require('./store');
-
-const FORMAT = 'openvibe.media.pastes-export';
-const DEFAULT_CUTOFF = '2026-08-20T03:00:26Z';
-// ac0d6a4 "Initial OpenVibe release" (2026-08-17 14:10:29 -07:00): earlier rows came from HoboStreamer's database.
-const DEFAULT_AMBIGUOUS_FROM = '2026-08-17T21:10:29Z';
-// Media rebuilt screenshot metadata itself, so the ai_moment flag Live sent was dropped for image
-// pastes; the frame file names Live's AI jobs upload are the remaining marker.
-const AI_FRAME_NAME = /^(ai-moment-vod\d+-\d+|live-\d+-\d+)\.jpe?g$/i;
-
-class ImportError extends Error {}
-class Rollback extends Error {}
-
-function toMs(ts) {
- if (!ts) return NaN;
- const s = String(ts);
- return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);
-}
-
-function objectOf(v) {
- if (v && typeof v === 'object' && !Array.isArray(v)) return v;
- if (typeof v === 'string') { try { const o = JSON.parse(v); return o && typeof o === 'object' && !Array.isArray(o) ? o : null; } catch { return null; } }
- return null;
-}
-
-function isAiPaste(p) {
- const meta = objectOf(p.metadata);
- if (meta && meta.ai_moment === true) return true;
- return p.type === 'screenshot' && !!meta && typeof meta.original_name === 'string' && AI_FRAME_NAME.test(meta.original_name);
-}
-
-/** Refuse a bundle that isn't exactly what it says it is. */
-function verifyBundle(bundle) {
- if (!bundle || typeof bundle !== 'object') throw new ImportError('bundle is not a JSON object');
- if (bundle.format !== FORMAT) throw new ImportError(`unexpected format ${JSON.stringify(bundle.format)} (want ${FORMAT})`);
- if (bundle.version !== 1) throw new ImportError(`unsupported version ${JSON.stringify(bundle.version)} (want 1)`);
- for (const k of ['pastes', 'comments', 'likes']) {
- if (!Array.isArray(bundle[k])) throw new ImportError(`bundle.${k} is not an array`);
- const want = bundle.counts && bundle.counts[k];
- if (want !== bundle[k].length) throw new ImportError(`count mismatch for ${k}: counts says ${want}, bundle has ${bundle[k].length}`);
- }
- const sha = crypto.createHash('sha256').update(JSON.stringify({ pastes: bundle.pastes, comments: bundle.comments, likes: bundle.likes })).digest('hex');
- if (sha !== String(bundle.sha256 || '').toLowerCase()) throw new ImportError(`sha256 mismatch: bundle says ${bundle.sha256}, content is ${sha}`);
-}
-
-/**
- * Import a verified bundle.
- * opts.resolveLegacy(system, ids) → Map<String(id), projection|null> (Network resolve-batch)
- * opts.cutoff ISO time of the id-space fix
- * opts.dryRun roll everything back at the end
- * opts.source label for migration_runs
- * → report
- */
-async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF, ambiguousFrom = DEFAULT_AMBIGUOUS_FROM, dryRun = false, source = 'media' } = {}) {
- verifyBundle(bundle);
- const cutoffMs = toMs(cutoff);
- if (!Number.isFinite(cutoffMs)) throw new ImportError(`bad cutoff ${cutoff}`);
- const fromMs = ambiguousFrom ? toMs(ambiguousFrom) : -Infinity;
- if (!Number.isFinite(fromMs) && fromMs !== -Infinity) throw new ImportError(`bad ambiguous-from ${ambiguousFrom}`);
- // Only rows written between the OpenVibe launch and the id-space fix can hold a Network id: rows from
- // before the launch were migrated from HoboStreamer's own database, whose user ids Live kept.
- const inWindow = (createdAt) => { const t = toMs(createdAt); return t >= fromMs && !(t >= cutoffMs); };
- const startedAt = new Date().toISOString();
-
- // ── 1. Resolve every legacy user id before the transaction (network calls) ──
- const liveIds = new Set();
- const preCutoffIds = new Set();
- const note = (userId, createdAt) => {
- if (userId == null) return;
- liveIds.add(String(userId));
- if (inWindow(createdAt)) preCutoffIds.add(String(userId));
- };
- for (const p of bundle.pastes) if (!isAiPaste(p)) note(p.user_id, p.created_at);
- for (const c of bundle.comments) note(c.user_id, c.created_at);
- for (const l of bundle.likes) note(l.user_id, l.created_at);
- const subjectOf = (proj) => (proj && proj.subject && proj.subject.type === 'user' ? proj.subject.id : null);
- const liveMap = liveIds.size ? await resolveLegacy('live', [...liveIds]) : new Map();
- const netMap = preCutoffIds.size ? await resolveLegacy('network', [...preCutoffIds]) : new Map();
-
- function owner(userId, createdAt) {
- if (userId == null) return { subject: null, hold: null };
- const key = String(userId);
- const live = subjectOf(liveMap.get(key));
- const pre = inWindow(createdAt);
- if (!pre) return live ? { subject: live, hold: null } : { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'post-cutoff' } };
- const net = subjectOf(netMap.get(key));
- if (live && net && live !== net) return { subject: null, hold: 'ambiguous_owner', detail: { user_id: userId, created_at: createdAt, live_subject: live, network_subject: net } };
- if (live || net) return { subject: live || net, hold: null };
- return { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'pre-cutoff' } };
- }
-
- const blank = () => ({ bundle: 0, inserted: 0, updated: 0, unchanged: 0, held: 0, skipped: 0 });
- const report = {
- source, app: bundle.app || null, generated_at: bundle.generated_at || null, since_id: bundle.since_id ?? null, max_id: bundle.max_id ?? null,
- cutoff, ambiguous_from: ambiguousFrom || null, dry_run: !!dryRun,
- pastes: { ...blank(), bundle: bundle.pastes.length, ai: 0, forks_remapped: 0, forks_unresolved: 0 },
- comments: { ...blank(), bundle: bundle.comments.length },
- likes: { ...blank(), bundle: bundle.likes.length },
- holds: {},
- users: { live_ids: liveIds.size, live_mapped: [...liveMap.values()].filter(subjectOf).length, network_checked: preCutoffIds.size, network_mapped: [...netMap.values()].filter(subjectOf).length },
- };
- const countHold = (reason) => { report.holds[reason] = (report.holds[reason] || 0) + 1; };
-
- const hold = db.prepare('INSERT OR IGNORE INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)');
- const clearOwnerHolds = db.prepare("DELETE FROM import_hold WHERE source_type = ? AND source_id = ? AND reason IN ('ambiguous_owner', 'owner_unmapped')");
- const byLegacy = db.prepare('SELECT * FROM pastes WHERE legacy_media_id = ?');
- const slugOwner = db.prepare('SELECT id, legacy_media_id FROM pastes WHERE slug = ?');
- const commentByLegacy = db.prepare('SELECT * FROM paste_comments WHERE legacy_media_id = ?');
-
- const run = db.transaction(() => {
- // Users seen: record the mappings the Network gave us.
- for (const [id, proj] of liveMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'live', 'user', id, 'subject', s); }
- for (const [id, proj] of netMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'network', 'user', id, 'subject', s); }
-
- // ── 2. Pastes ──
- const PASTE_TEXT_FIELDS = ['type', 'title', 'content', 'language', 'visibility', 'screenshot_url', 'media_ref', 'stream_ref', 'metadata',
- 'burn_after_read', 'pinned', 'is_nsfw', 'ai_summary', 'ai_tags', 'ai_analyzed_at', 'origin', 'updated_at'];
- const COUNTERS = ['views', 'unique_views', 'copies', 'likes'];
- for (const p of bundle.pastes) {
- const ai = isAiPaste(p);
- if (ai) report.pastes.ai++;
- const who = ai ? { subject: null, hold: null } : owner(p.user_id, p.created_at);
- const meta = objectOf(p.metadata);
- const streamId = p.stream_id != null ? p.stream_id : (meta && meta.stream_id != null ? meta.stream_id : null);
- const row = {
- slug: String(p.slug),
- origin: ai ? 'ai' : 'user',
- type: p.type === 'screenshot' ? 'screenshot' : 'paste',
- title: store.sanitizeTitle(p.title),
- content: p.content == null ? null : String(p.content),
- language: p.language || 'text',
- visibility: ['public', 'unlisted', 'private'].includes(p.visibility) ? p.visibility : 'public',
- screenshot_url: p.screenshot_url || null,
- media_ref: p.type === 'screenshot' ? ids.legacyMediaId(bundle.app || 'live', 'paste', p.id) : null,
- stream_ref: streamId != null && streamId !== '' ? JSON.stringify({ service: 'live', type: 'stream', id: String(streamId) }) : null,
- metadata: meta ? JSON.stringify(meta) : null,
- burn_after_read: p.burn_after_read ? 1 : 0,
- pinned: p.pinned ? 1 : 0,
- is_nsfw: p.is_nsfw ? 1 : 0,
- ai_summary: p.ai_summary || null,
- ai_tags: p.ai_tags == null ? null : (typeof p.ai_tags === 'string' ? p.ai_tags : JSON.stringify(p.ai_tags)),
- ai_analyzed_at: p.ai_analyzed_at || null,
- views: Number(p.views) || 0, unique_views: Number(p.unique_views) || 0, copies: Number(p.copies) || 0, likes: Number(p.likes) || 0,
- created_at: p.created_at, updated_at: p.updated_at || p.created_at,
- };
- const existing = byLegacy.get(p.id);
- let communityId;
- if (!existing) {
- const clash = slugOwner.get(row.slug);
- if (clash) {
- hold.run('paste', String(p.id), 'slug_conflict', JSON.stringify({ slug: row.slug, community_id: clash.id }));
- countHold('slug_conflict'); report.pastes.skipped++;
- continue;
- }
- const cols = Object.keys(row).concat(['owner_subject', 'legacy_media_id', 'legacy_user_id']);
- const vals = Object.values(row).concat([who.subject, p.id, p.user_id == null ? null : Number(p.user_id)]);
- const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...vals);
- communityId = info.lastInsertRowid;
- report.pastes.inserted++;
- } else {
- communityId = existing.id;
- if (existing.deleted_at) { report.pastes.unchanged++; store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId); continue; }
- const patch = {};
- // Edited here since the last import? Then Community's text wins over the bundle's.
- const localNewer = existing.revision > 1 || toMs(existing.updated_at) > toMs(row.updated_at);
- if (!localNewer) for (const k of PASTE_TEXT_FIELDS) if (row[k] !== existing[k]) patch[k] = row[k];
- for (const k of COUNTERS) if (row[k] > existing[k]) patch[k] = row[k];
- if (who.subject && !existing.owner_subject) patch.owner_subject = who.subject;
- if (row.origin === 'ai' && existing.owner_subject && existing.origin !== 'ai') patch.owner_subject = null;
- const legacyUser = p.user_id == null ? null : Number(p.user_id);
- if (legacyUser !== existing.legacy_user_id) patch.legacy_user_id = legacyUser;
- const keys = Object.keys(patch);
- if (keys.length) {
- db.prepare(`UPDATE pastes SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), communityId);
- report.pastes.updated++;
- } else {
- report.pastes.unchanged++;
- }
- }
- store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId);
- if (who.hold) {
- hold.run('paste', String(p.id), who.hold, JSON.stringify(who.detail));
- countHold(who.hold); report.pastes.held++;
- } else {
- clearOwnerHolds.run('paste', String(p.id));
- }
- }
- // Forks point at Community ids (second pass: the original may come later in the bundle).
- for (const p of bundle.pastes) {
- if (p.forked_from == null) continue;
- const target = store.mapGet(db, 'media', 'paste', p.forked_from);
- const self = store.mapGet(db, 'media', 'paste', p.id);
- if (!self) continue;
- if (!target) { report.pastes.forks_unresolved++; continue; }
- const changed = db.prepare('UPDATE pastes SET forked_from = ? WHERE id = ? AND forked_from IS NOT ?').run(Number(target.target_id), Number(self.target_id), Number(target.target_id)).changes;
- if (changed) report.pastes.forks_remapped++;
- }
-
- // ── 3. Comments ──
- const comments = bundle.comments.slice().sort((a, b) => a.id - b.id);
- for (const c of comments) {
- const paste = store.mapGet(db, 'media', 'paste', c.paste_id);
- if (!paste) {
- hold.run('comment', String(c.id), 'paste_missing', JSON.stringify({ paste_id: c.paste_id }));
- countHold('paste_missing'); report.comments.skipped++;
- continue;
- }
- const who = owner(c.user_id, c.created_at);
- const row = {
- paste_id: Number(paste.target_id), anon_name: c.anon_name || null, message: String(c.message == null ? '' : c.message),
- is_deleted: c.is_deleted ? 1 : 0, created_at: c.created_at, updated_at: c.updated_at || c.created_at,
- };
- const existing = commentByLegacy.get(c.id);
- let cid;
- if (!existing) {
- const info = db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, message, is_deleted, created_at, updated_at, legacy_media_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
- .run(row.paste_id, who.subject, row.anon_name, row.message, row.is_deleted, row.created_at, row.updated_at, c.id);
- cid = info.lastInsertRowid;
- report.comments.inserted++;
- } else {
- cid = existing.id;
- const patch = {};
- for (const k of ['message', 'anon_name', 'updated_at']) if (row[k] !== existing[k]) patch[k] = row[k];
- // Deletion is one-way: a comment removed on either side stays removed.
- if (row.is_deleted && !existing.is_deleted) patch.is_deleted = 1;
- if (who.subject && !existing.author_subject) patch.author_subject = who.subject;
- const keys = Object.keys(patch);
- if (keys.length) {
- db.prepare(`UPDATE paste_comments SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), cid);
- report.comments.updated++;
- } else {
- report.comments.unchanged++;
- }
- }
- store.mapSet(db, 'media', 'comment', c.id, 'comment', cid);
- if (who.hold) {
- hold.run('comment', String(c.id), who.hold, JSON.stringify(who.detail));
- countHold(who.hold); report.comments.held++;
- } else {
- clearOwnerHolds.run('comment', String(c.id));
- }
- }
- for (const c of comments) {
- if (c.parent_id == null) continue;
- const self = store.mapGet(db, 'media', 'comment', c.id);
- const parent = store.mapGet(db, 'media', 'comment', c.parent_id);
- if (self && parent) db.prepare('UPDATE paste_comments SET parent_id = ? WHERE id = ? AND parent_id IS NOT ?').run(Number(parent.target_id), Number(self.target_id), Number(parent.target_id));
- }
-
- // ── 4. Likes (only ever imported for a mapped person) ──
- for (const l of bundle.likes) {
- const key = `${l.paste_id}:${l.user_id}`;
- const paste = store.mapGet(db, 'media', 'paste', l.paste_id);
- if (!paste) {
- hold.run('like', key, 'paste_missing', JSON.stringify({ paste_id: l.paste_id, user_id: l.user_id }));
- countHold('paste_missing'); report.likes.skipped++;
- continue;
- }
- const who = l.user_id == null ? { subject: null, hold: 'owner_unmapped', detail: { user_id: null } } : owner(l.user_id, l.created_at);
- if (!who.subject) {
- hold.run('like', key, who.hold, JSON.stringify(who.detail));
- countHold(who.hold); report.likes.held++;
- continue;
- }
- clearOwnerHolds.run('like', key);
- const info = db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id, created_at) VALUES (?, ?, ?)').run(Number(paste.target_id), who.subject, l.created_at || null);
- if (info.changes) report.likes.inserted++; else report.likes.unchanged++;
- }
-
- report.finished_at = new Date().toISOString();
- db.prepare('INSERT INTO migration_runs (source, started_at, finished_at, report) VALUES (?, ?, ?, ?)')
- .run(source, startedAt, report.finished_at, JSON.stringify(report));
- if (dryRun) throw new Rollback();
- });
-
- try { run(); } catch (err) { if (!(err instanceof Rollback)) throw err; }
- return report;
-}
-
-/** Plain-text reconciliation table. */
-function formatReport(r) {
- const line = (name, t) => ` ${name.padEnd(9)} ${String(t.bundle).padStart(7)} ${String(t.inserted).padStart(9)} ${String(t.updated).padStart(8)} ${String(t.unchanged).padStart(10)} ${String(t.held).padStart(6)} ${String(t.skipped).padStart(8)}`;
- const out = [
- `Paste import from ${r.source}${r.dry_run ? ' (DRY RUN — rolled back)' : ''}`,
- ` bundle app=${r.app} ids ${r.since_id ?? '?'}..${r.max_id ?? '?'} generated ${r.generated_at || '?'}; ambiguous id window ${r.ambiguous_from || 'start'} .. ${r.cutoff}`,
- '',
- ` ${'table'.padEnd(9)} ${'bundle'.padStart(7)} ${'inserted'.padStart(9)} ${'updated'.padStart(8)} ${'unchanged'.padStart(10)} ${'held'.padStart(6)} ${'skipped'.padStart(8)}`,
- line('pastes', r.pastes), line('comments', r.comments), line('likes', r.likes),
- '',
- ` AI pastes (ownerless): ${r.pastes.ai}; forks remapped: ${r.pastes.forks_remapped}, unresolved: ${r.pastes.forks_unresolved}`,
- ` users: ${r.users.live_ids} live ids (${r.users.live_mapped} mapped), ${r.users.network_checked} in-window ids checked as network ids (${r.users.network_mapped} mapped)`,
- ` holds: ${Object.keys(r.holds).length ? Object.entries(r.holds).map(([k, v]) => `${k}=${v}`).join(', ') : 'none'}`,
- ];
- return out.join('\n');
-}
-
-module.exports = { DEFAULT_AMBIGUOUS_FROM, importBundle, verifyBundle, formatReport, isAiPaste, ImportError, FORMAT, DEFAULT_CUTOFF };
@@ -68,7 +68,7 @@ function sinceParam(value) {
@@ -68,7 +68,7 @@ function sinceParam(value) {
function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {
function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
const L = { ...DEFAULT_LIMITS, ...limits };
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
- const tell = (fn) => { if (!pulse) return; try { fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
+ const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
const visitorSecret = process.env.VIEW_HASH_SECRET
const visitorSecret = process.env.VIEW_HASH_SECRET
|| crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');
|| crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');
@@ -86,8 +86,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -86,8 +86,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
* A paste the viewer may see, or 404: private ones look exactly like missing ones to everyone
* A paste the viewer may see, or 404: private ones look exactly like missing ones to everyone
* but their owner and staff — for reads, and for edits/deletes too (no 403 that confirms a slug).
* but their owner and staff — for reads, and for edits/deletes too (no 403 that confirms a slug).
*/
*/
- function visible(v, slug) {
- const p = store.getBySlug(db, String(slug));
+ async function visible(v, slug) {
+ const p = await store.getBySlug(db, String(slug));
if (!p || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Paste not found');
if (!p || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Paste not found');
return p;
return p;
}
}
@@ -153,8 +153,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -153,8 +153,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
}
async function projectionsFor(subjects) {
async function projectionsFor(subjects) {
- if (!network) return store.getProjections(db, subjects);
- try { return await network.projections(subjects); } catch { return store.getProjections(db, subjects); }
+ if (!network) return await store.getProjections(db, subjects);
+ try { return await network.projections(subjects); } catch { return await store.getProjections(db, subjects); }
}
}
async function shapeMany(rows, v, { preview = false } = {}) {
async function shapeMany(rows, v, { preview = false } = {}) {
@@ -179,16 +179,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -179,16 +179,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
}
// ── rate limits (acting persons) ─────────────────────────
// ── rate limits (acting persons) ─────────────────────────
- function pasteRateCheck(v) {
+ async function pasteRateCheck(v) {
if (!limited(v)) return;
if (!limited(v)) return;
if (L.cooldownSeconds > 0) {
if (L.cooldownSeconds > 0) {
- const elapsed = (Date.now() - store.lastPasteTime(db, v.subject)) / 1000;
+ const elapsed = (Date.now() - await store.lastPasteTime(db, v.subject)) / 1000;
if (elapsed < L.cooldownSeconds) {
if (elapsed < L.cooldownSeconds) {
const wait = Math.ceil(L.cooldownSeconds - elapsed);
const wait = Math.ceil(L.cooldownSeconds - elapsed);
fail(429, `Please wait ${wait}s before creating another paste`, { cooldown: wait });
fail(429, `Please wait ${wait}s before creating another paste`, { cooldown: wait });
}
}
}
}
- if (L.maxPerUserPerDay > 0 && store.countOwnerSince(db, v.subject, '-1 day') >= L.maxPerUserPerDay) {
+ if (L.maxPerUserPerDay > 0 && await store.countOwnerSince(db, v.subject, '-1 day') >= L.maxPerUserPerDay) {
fail(429, `Daily paste limit reached (${L.maxPerUserPerDay}/day)`);
fail(429, `Daily paste limit reached (${L.maxPerUserPerDay}/day)`);
}
}
}
}
@@ -220,25 +220,25 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -220,25 +220,25 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
b.n += 1;
b.n += 1;
return b.n > L.viewEventsPerMinPerIp;
return b.n > L.viewEventsPerMinPerIp;
}
}
- const sweep = setInterval(() => {
+ const sweep = setInterval(async () => {
const now = Date.now();
const now = Date.now();
for (const [k, b] of ipBudget) if (now - b.start > 120_000) ipBudget.delete(k);
for (const [k, b] of ipBudget) if (now - b.start > 120_000) ipBudget.delete(k);
for (const [k, l] of recentComments) if (!l.length || now - l[0].at > 120_000) recentComments.delete(k);
for (const [k, l] of recentComments) if (!l.length || now - l[0].at > 120_000) recentComments.delete(k);
- try { store.pruneVisits(db, 30); } catch { /* db closed in tests */ }
+ try { await store.pruneVisits(db, 30); } catch { /* db closed in tests */ }
}, 10 * 60_000);
}, 10 * 60_000);
if (sweep.unref) sweep.unref();
if (sweep.unref) sweep.unref();
- function countView(v, p, { ip, userAgent } = {}) {
+ async function countView(v, p, { ip, userAgent } = {}) {
if (BOT_UA.test(String(userAgent || ''))) return;
if (BOT_UA.test(String(userAgent || ''))) return;
if (overBudget(ip || 'unknown')) return;
if (overBudget(ip || 'unknown')) return;
const visitor = v && v.subject ? `u:${v.subject}` : `ip:${crypto.createHmac('sha256', visitorSecret).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
const visitor = v && v.subject ? `u:${v.subject}` : `ip:${crypto.createHmac('sha256', visitorSecret).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
- const r = store.recordVisit(db, p.id, visitor, L.viewCooldownSec);
+ const r = await store.recordVisit(db, p.id, visitor, L.viewCooldownSec);
p.views = r.views; p.unique_views = r.unique_views;
p.views = r.views; p.unique_views = r.unique_views;
}
}
/** Burn-after-read: the paste is gone the moment this read makes it spent. */
/** Burn-after-read: the paste is gone the moment this read makes it spent. */
- function burn(p) {
- store.softDelete(db, p.id);
+ async function burn(p) {
+ await store.softDelete(db, p.id);
fail(410, 'This paste has been burned after reading.');
fail(410, 'This paste has been burned after reading.');
}
}
@@ -277,16 +277,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -277,16 +277,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
* unlisted or private paste always gets a secret slug: its slug is its only protection, and a
* unlisted or private paste always gets a secret slug: its slug is its only protection, and a
* service forwarding a person's request (Live's /api/pastes) passes their body through as-is.
* service forwarding a person's request (Live's /api/pastes) passes their body through as-is.
*/
*/
- function slugFor(v, body, visibility) {
- if (!isService(v) || body.slug == null || body.slug === '' || visibility !== 'public') return store.generateSlug(db, { secret: visibility !== 'public' });
+ async function slugFor(v, body, visibility) {
+ if (!isService(v) || body.slug == null || body.slug === '' || visibility !== 'public') return await store.generateSlug(db, { secret: visibility !== 'public' });
const slug = String(body.slug);
const slug = String(body.slug);
if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
- if (db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');
+ if (await db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');
return slug;
return slug;
}
}
- function created(row, extra = {}) {
- tell((p) => p.pasteCreated(row));
+ async function created(row, extra = {}) {
+ await tell((p) => p.pasteCreated(row));
return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
}
}
@@ -308,79 +308,79 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -308,79 +308,79 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
// ?username= lists one person's pastes (Live resolved it to its own id; we go through
// ?username= lists one person's pastes (Live resolved it to its own id; we go through
// the projection cache). Their own unlisted/private ones only for them (or staff).
// the projection cache). Their own unlisted/private ones only for them (or staff).
if (q.username && q.username !== 'all') {
if (q.username && q.username !== 'all') {
- const subject = store.subjectsByUsername(db, q.username)[0];
+ const subject = (await store.subjectsByUsername(db, q.username))[0];
if (!subject) return { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false };
if (!subject) return { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false };
opts.ownerSubject = subject;
opts.ownerSubject = subject;
if (truthy(q.include_unlisted) && (v.subject === subject || isStaff(v))) opts.includeHidden = true;
if (truthy(q.include_unlisted) && (v.subject === subject || isStaff(v))) opts.includeHidden = true;
}
}
- const { rows, total } = store.listPastes(db, opts);
+ const { rows, total } = await store.listPastes(db, opts);
return { pastes: await shapeMany(rows, v, { preview: true }), total, limit, offset };
return { pastes: await shapeMany(rows, v, { preview: true }), total, limit, offset };
},
},
/** GET /api/pastes/by-user/:username */
/** GET /api/pastes/by-user/:username */
async byUser(v, username, q = {}) {
async byUser(v, username, q = {}) {
- const subject = store.subjectsByUsername(db, username)[0];
+ const subject = (await store.subjectsByUsername(db, username))[0];
if (!subject) fail(404, 'User not found');
if (!subject) fail(404, 'User not found');
const limit = intIn(q.limit, 30, 1, 100);
const limit = intIn(q.limit, 30, 1, 100);
const includeHidden = v.subject === subject;
const includeHidden = v.subject === subject;
- const { rows, total } = store.listPastes(db, { ownerSubject: subject, includeHidden, limit, offset: 0, sort: q.sort === 'oldest' ? 'oldest' : 'newest' });
- const proj = store.getProjections(db, [subject]).get(subject);
+ const { rows, total } = await store.listPastes(db, { ownerSubject: subject, includeHidden, limit, offset: 0, sort: q.sort === 'oldest' ? 'oldest' : 'newest' });
+ const proj = (await store.getProjections(db, [subject])).get(subject);
return { pastes: await shapeMany(rows, v, { preview: true }), total, username: (proj && proj.username) || String(username) };
return { pastes: await shapeMany(rows, v, { preview: true }), total, username: (proj && proj.username) || String(username) };
},
},
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
async get(v, slug, ctx = {}) {
async get(v, slug, ctx = {}) {
- const p = visible(v, slug);
+ const p = await visible(v, slug);
// ?no_view=1 skips view counting, never the burn: any read of a burn paste is the read.
// ?no_view=1 skips view counting, never the burn: any read of a burn paste is the read.
if ((!ctx.noView || p.burn_after_read) && !isOwner(v, p)) {
if ((!ctx.noView || p.burn_after_read) && !isOwner(v, p)) {
if (p.burn_after_read) {
if (p.burn_after_read) {
- p.views = store.bumpViews(db, p.id);
- if (p.views > 1) burn(p);
+ p.views = await store.bumpViews(db, p.id);
+ if (p.views > 1) await burn(p);
} else {
} else {
- countView(v, p, ctx);
+ await countView(v, p, ctx);
}
}
}
}
- const liked = v.subject ? store.hasLiked(db, p.id, v.subject) : false;
+ const liked = v.subject ? await store.hasLiked(db, p.id, v.subject) : false;
return { paste: { ...(await shapeOne(p, v)), liked } };
return { paste: { ...(await shapeOne(p, v)), liked } };
},
},
/** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */
/** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */
- raw(v, slug, ctx = {}) {
- const p = store.getBySlug(db, String(slug));
+ async raw(v, slug, ctx = {}) {
+ const p = await store.getBySlug(db, String(slug));
if (p && p.type === 'screenshot' && (p.visibility !== 'private' || canSeeHidden(v, p))) return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };
if (p && p.type === 'screenshot' && (p.visibility !== 'private' || canSeeHidden(v, p))) return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };
if (!p || p.type !== 'paste' || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
if (!p || p.type !== 'paste' || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
if (!isOwner(v, p)) {
if (!isOwner(v, p)) {
if (p.burn_after_read) {
if (p.burn_after_read) {
- if (p.views > 0) burn(p);
- store.bumpViews(db, p.id);
+ if (p.views > 0) await burn(p);
+ await store.bumpViews(db, p.id);
} else {
} else {
- countView(v, p, ctx);
+ await countView(v, p, ctx);
}
}
}
}
return { content: String(p.content || '') };
return { content: String(p.content || '') };
},
},
/** GET /p/:slug/screenshot target (the stored Media URL). For a burn-after-read screenshot it is a read (raw's rule). */
/** GET /p/:slug/screenshot target (the stored Media URL). For a burn-after-read screenshot it is a read (raw's rule). */
- screenshotUrl(v, slug) {
- const p = store.getBySlug(db, String(slug));
+ async screenshotUrl(v, slug) {
+ const p = await store.getBySlug(db, String(slug));
if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
if (p.burn_after_read && !isOwner(v, p)) {
if (p.burn_after_read && !isOwner(v, p)) {
- if (p.views > 0) burn(p);
- store.bumpViews(db, p.id);
+ if (p.views > 0) await burn(p);
+ await store.bumpViews(db, p.id);
}
}
return p.screenshot_url;
return p.screenshot_url;
},
},
/** POST /api/pastes (text) */
/** POST /api/pastes (text) */
async createText(v, body = {}) {
async createText(v, body = {}) {
- pasteRateCheck(v);
+ await pasteRateCheck(v);
const content = body.content;
const content = body.content;
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
const metadata = serviceMetadata(v, body);
const metadata = serviceMetadata(v, body);
const visibility = visibilityOf(body.visibility, !!v.subject);
const visibility = visibilityOf(body.visibility, !!v.subject);
- const row = store.insertPaste(db, {
- slug: slugFor(v, body, visibility),
+ const row = await store.insertPaste(db, {
+ slug: await slugFor(v, body, visibility),
owner_subject: v.subject || null,
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
type: 'paste',
@@ -394,18 +394,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -394,18 +394,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
...aiFields(v, body),
...aiFields(v, body),
});
});
- return created(row, { paste: await shapeOne(row, v) });
+ return await created(row, { paste: await shapeOne(row, v) });
},
},
/** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
/** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
async createScreenshot(v, body = {}, file, ctx = {}) {
async createScreenshot(v, body = {}, file, ctx = {}) {
- pasteRateCheck(v);
+ await pasteRateCheck(v);
if (!file || !file.buffer) fail(400, 'No screenshot uploaded');
if (!file || !file.buffer) fail(400, 'No screenshot uploaded');
if (!IMAGE_MIME.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');
if (!IMAGE_MIME.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');
if (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);
if (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);
if (!media) fail(503, 'Media service unavailable');
if (!media) fail(503, 'Media service unavailable');
const visibility = visibilityOf(body.visibility, !!v.subject);
const visibility = visibilityOf(body.visibility, !!v.subject);
- const slug = slugFor(v, body, visibility);
+ const slug = await slugFor(v, body, visibility);
const extra = serviceMetadata(v, body);
const extra = serviceMetadata(v, body);
const bytes = stripImageMetadata(file.buffer, file.mimetype);
const bytes = stripImageMetadata(file.buffer, file.mimetype);
let stored;
let stored;
@@ -423,7 +423,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -423,7 +423,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
size_bytes: bytes.length,
size_bytes: bytes.length,
mime_type: file.mimetype,
mime_type: file.mimetype,
};
};
- const row = store.insertPaste(db, {
+ const row = await store.insertPaste(db, {
slug,
slug,
owner_subject: v.subject || null,
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
origin: v.origin === 'ai' ? 'ai' : 'user',
@@ -440,13 +440,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -440,13 +440,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
...aiFields(v, body),
...aiFields(v, body),
});
});
- return created(row, { paste: await shapeOne(row, v) });
+ return await created(row, { paste: await shapeOne(row, v) });
},
},
/** PUT /api/pastes/:slug — owner (or staff) only. */
/** PUT /api/pastes/:slug — owner (or staff) only. */
async update(v, slug, body = {}) {
async update(v, slug, body = {}) {
needIdentity(v);
needIdentity(v);
- const p = visible(v, slug);
+ const p = await visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
const patch = {};
const patch = {};
if (body.title !== undefined) patch.title = store.sanitizeTitle(body.title);
if (body.title !== undefined) patch.title = store.sanitizeTitle(body.title);
@@ -460,49 +460,49 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -460,49 +460,49 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;
if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;
if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
- const row = db.transaction(() => {
- const r = store.updatePaste(db, p.id, patch, v.subject || null);
+ const row = await db.tx(async () => {
+ const r = await store.updatePaste(db, p.id, patch, v.subject || null);
// Staff editing someone else's paste goes to the moderation audit log (ADR-022).
// Staff editing someone else's paste goes to the moderation audit log (ADR-022).
- if (!isOwner(v, p) && isStaff(v)) events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });
+ if (!isOwner(v, p) && isStaff(v)) await events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });
return r;
return r;
- })();
- tell((pl) => pl.pasteChanged(row));
+ });
+ await tell((pl) => pl.pasteChanged(row));
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
/** DELETE /api/pastes/:slug — owner (or staff) only. */
/** DELETE /api/pastes/:slug — owner (or staff) only. */
- remove(v, slug) {
+ async remove(v, slug) {
needIdentity(v);
needIdentity(v);
- const p = visible(v, slug);
+ const p = await visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
- db.transaction(() => {
- store.softDelete(db, p.id);
- if (!isOwner(v, p)) events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
- })();
- tell((pl) => pl.pasteGone(p.slug));
+ await db.tx(async () => {
+ await store.softDelete(db, p.id);
+ if (!isOwner(v, p)) await events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
+ });
+ await tell((pl) => pl.pasteGone(p.slug));
return { success: true };
return { success: true };
},
},
/** GET /api/pastes/:slug/versions — edit history, owner (or staff) only. */
/** GET /api/pastes/:slug/versions — edit history, owner (or staff) only. */
- versions(v, slug) {
+ async versions(v, slug) {
needIdentity(v);
needIdentity(v);
- const p = visible(v, slug);
+ const p = await visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
- const list = store.listVersions(db, p.id);
+ const list = await store.listVersions(db, p.id);
return { revision: p.revision, versions: list.length ? list : [{ revision: p.revision, title: p.title, content: p.content, language: p.language, edited_by: p.owner_subject, created_at: p.updated_at }] };
return { revision: p.revision, versions: list.length ? list : [{ revision: p.revision, title: p.title, content: p.content, language: p.language, edited_by: p.owner_subject, created_at: p.updated_at }] };
},
},
/** POST /api/pastes/:slug/fork */
/** POST /api/pastes/:slug/fork */
async fork(v, slug) {
async fork(v, slug) {
- const original = visible(v, slug);
+ const original = await visible(v, slug);
if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');
if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');
// A lasting copy would outlive the burn; only the owner (or staff) may fork one.
// A lasting copy would outlive the burn; only the owner (or staff) may fork one.
if (original.burn_after_read && !canSeeHidden(v, original)) fail(403, 'Burn-after-read pastes cannot be forked');
if (original.burn_after_read && !canSeeHidden(v, original)) fail(403, 'Burn-after-read pastes cannot be forked');
- pasteRateCheck(v);
+ await pasteRateCheck(v);
// A fork never widens who can find the content: unlisted stays unlisted (and gets a secret slug).
// A fork never widens who can find the content: unlisted stays unlisted (and gets a secret slug).
const visibility = visibilityOf(original.visibility, !!v.subject);
const visibility = visibilityOf(original.visibility, !!v.subject);
- const row = store.insertPaste(db, {
- slug: store.generateSlug(db, { secret: visibility !== 'public' }),
+ const row = await store.insertPaste(db, {
+ slug: await store.generateSlug(db, { secret: visibility !== 'public' }),
owner_subject: v.subject || null,
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
type: 'paste',
@@ -512,37 +512,37 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -512,37 +512,37 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
visibility,
visibility,
forked_from: original.id,
forked_from: original.id,
});
});
- return created(row, { paste: await shapeOne(row, v) });
+ return await created(row, { paste: await shapeOne(row, v) });
},
},
/** POST /api/pastes/:slug/like — toggles; needs a person. */
/** POST /api/pastes/:slug/like — toggles; needs a person. */
- like(v, slug) {
+ async like(v, slug) {
if (!v.subject) fail(401, 'Authentication required');
if (!v.subject) fail(401, 'Authentication required');
- const p = visible(v, slug);
- return store.toggleLike(db, p.id, v.subject);
+ const p = await visible(v, slug);
+ return await store.toggleLike(db, p.id, v.subject);
},
},
/** POST /api/pastes/:slug/copy */
/** POST /api/pastes/:slug/copy */
- copy(v, slug) {
- const p = visible(v, slug);
- return { copies: store.incrementCopies(db, p.id) };
+ async copy(v, slug) {
+ const p = await visible(v, slug);
+ return { copies: await store.incrementCopies(db, p.id) };
},
},
/** GET /api/pastes/:slug/comments */
/** GET /api/pastes/:slug/comments */
async comments(v, slug, q = {}) {
async comments(v, slug, q = {}) {
- const p = visible(v, slug);
+ const p = await visible(v, slug);
const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);
const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);
const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);
const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);
- const list = store.listComments(db, p.id, limit, offset);
+ const list = await store.listComments(db, p.id, limit, offset);
const subjects = [];
const subjects = [];
for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }
for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }
const projections = await projectionsFor(subjects);
const projections = await projectionsFor(subjects);
- return { comments: list.map((c) => shapeComment(c, projections)), total: store.countComments(db, p.id) };
+ return { comments: list.map((c) => shapeComment(c, projections)), total: await store.countComments(db, p.id) };
},
},
/** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */
/** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */
async addComment(v, slug, body = {}, ctx = {}) {
async addComment(v, slug, body = {}, ctx = {}) {
- const p = visible(v, slug);
+ const p = await visible(v, slug);
const author = v.subject || null;
const author = v.subject || null;
if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');
if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');
const message = String(body.message || '').trim();
const message = String(body.message || '').trim();
@@ -558,85 +558,85 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -558,85 +558,85 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
let parent = null;
let parent = null;
if (parentId) {
if (parentId) {
- parent = store.getComment(db, parentId);
+ parent = await store.getComment(db, parentId);
if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');
if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');
if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
}
}
// Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.
// Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.
- if (author && blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });
- if (author && parent && blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });
- const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });
+ if (author && await blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });
+ if (author && parent && await blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });
+ const c = await store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });
if (limited(v)) commentRecorded(rateKey, message);
if (limited(v)) commentRecorded(rateKey, message);
return { comment: shapeComment(c, await projectionsFor([author])) };
return { comment: shapeComment(c, await projectionsFor([author])) };
},
},
/** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */
/** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */
- deleteComment(v, slug, commentId) {
+ async deleteComment(v, slug, commentId) {
needIdentity(v);
needIdentity(v);
- const p = visible(v, slug);
- const c = store.getComment(db, parseInt(commentId, 10));
+ const p = await visible(v, slug);
+ const c = await store.getComment(db, parseInt(commentId, 10));
if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
- db.transaction(() => {
- store.softDeleteComment(db, c.id);
- if (!isAuthor && !isOwner(v, p)) events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });
- })();
+ await db.tx(async () => {
+ await store.softDeleteComment(db, c.id);
+ if (!isAuthor && !isOwner(v, p)) await events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });
+ });
return { message: 'Comment deleted' };
return { message: 'Comment deleted' };
},
},
/** GET /api/pastes/config */
/** GET /api/pastes/config */
- config(v) {
+ async config(v) {
return {
return {
maxSizeKb: L.maxSizeKb,
maxSizeKb: L.maxSizeKb,
screenshotMaxSizeMb: L.screenshotMaxSizeMb,
screenshotMaxSizeMb: L.screenshotMaxSizeMb,
cooldownSeconds: L.cooldownSeconds,
cooldownSeconds: L.cooldownSeconds,
maxPerUserPerDay: L.maxPerUserPerDay,
maxPerUserPerDay: L.maxPerUserPerDay,
- todayCount: v.subject ? store.countOwnerSince(db, v.subject, '-1 day') : 0,
+ todayCount: v.subject ? await store.countOwnerSince(db, v.subject, '-1 day') : 0,
};
};
},
},
// ── staff (api.js has already checked the viewer is staff) ──
// ── staff (api.js has already checked the viewer is staff) ──
- stats() { return { stats: store.stats(db) }; },
+ async stats() { return { stats: await store.stats(db) }; },
- forks(q = {}) {
+ async forks(q = {}) {
const limit = intIn(q.limit, 100, 1, 500);
const limit = intIn(q.limit, 100, 1, 500);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
- const { forks, total } = store.listForks(db, limit, offset);
+ const { forks, total } = await store.listForks(db, limit, offset);
return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };
return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };
},
},
- deleteForks(v) {
- return db.transaction(() => {
- const deleted = store.deleteAllForks(db);
- events.moderationAction(v, 'forks.deleted', { type: 'pastes', id: 'forks' }, { details: { deleted } });
+ async deleteForks(v) {
+ return await db.tx(async () => {
+ const deleted = await store.deleteAllForks(db);
+ await events.moderationAction(v, 'forks.deleted', { type: 'pastes', id: 'forks' }, { details: { deleted } });
return { success: true, deleted };
return { success: true, deleted };
- })();
+ });
},
},
- bulk(body = {}, v = null) {
+ async bulk(body = {}, v = null) {
const { slugs, action } = body;
const { slugs, action } = body;
if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');
if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');
if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');
if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');
let done = 0, skipped = 0;
let done = 0, skipped = 0;
- db.transaction(() => {
+ await db.tx(async () => {
for (const slug of slugs.slice(0, 500)) {
for (const slug of slugs.slice(0, 500)) {
- const p = store.getBySlug(db, String(slug));
+ const p = await store.getBySlug(db, String(slug));
if (!p) { skipped++; continue; }
if (!p) { skipped++; continue; }
- if (action === 'delete') store.softDelete(db, p.id);
- else store.setVisibility(db, p.id, action);
- if (action !== 'public') tell((pl) => pl.pasteGone(p.slug));
+ if (action === 'delete') await store.softDelete(db, p.id);
+ else await store.setVisibility(db, p.id, action);
+ if (action !== 'public') await tell((pl) => pl.pasteGone(p.slug));
done++;
done++;
}
}
- if (done) events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });
- })();
+ if (done) await events.moderationAction(v, 'pastes.bulk', { type: 'pastes', id: 'bulk' }, { details: { bulk_action: action, done, skipped } });
+ });
return { done, skipped };
return { done, skipped };
},
},
/** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */
/** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */
async censor(v, slugOrId, file) {
async censor(v, slugOrId, file) {
- let p = store.getBySlug(db, String(slugOrId));
- if (!p && /^\d+$/.test(String(slugOrId))) p = store.getById(db, parseInt(slugOrId, 10));
+ let p = await store.getBySlug(db, String(slugOrId));
+ if (!p && /^\d+$/.test(String(slugOrId))) p = await store.getById(db, parseInt(slugOrId, 10));
if (!p) fail(404, 'Paste not found');
if (!p) fail(404, 'Paste not found');
if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (!file || !file.buffer) fail(400, 'Censored image is required');
if (!file || !file.buffer) fail(400, 'Censored image is required');
@@ -649,21 +649,21 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -649,21 +649,21 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
console.warn('[Pastes] censor upload failed:', err.message);
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
}
}
- const row = db.transaction(() => {
- const r = store.setScreenshot(db, p.id, stored.url, stored.media_ref);
- events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
+ const row = await db.tx(async () => {
+ const r = await store.setScreenshot(db, p.id, stored.url, stored.media_ref);
+ await events.moderationAction(v, 'paste.censored', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
return r;
return r;
- })();
+ });
return { paste: await shapeOne(row, v) };
return { paste: await shapeOne(row, v) };
},
},
/** POST /api/pastes/:slug/ai — the AI pass writes its results back. */
/** POST /api/pastes/:slug/ai — the AI pass writes its results back. */
async setAi(v, slug, body = {}) {
async setAi(v, slug, body = {}) {
- const p = store.getBySlug(db, String(slug));
+ const p = await store.getBySlug(db, String(slug));
if (!p) fail(404, 'Paste not found');
if (!p) fail(404, 'Paste not found');
const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
- const row = store.setAi(db, p.id, summary, tags);
+ const row = await store.setAi(db, p.id, summary, tags);
return { ok: true, paste: await shapeOne(row, v) };
return { ok: true, paste: await shapeOne(row, v) };
},
},
};
};
@@ -61,7 +61,7 @@ const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words
@@ -61,7 +61,7 @@ const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words
* characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these
* characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these
* cannot. Existing slugs never change.
* cannot. Existing slugs never change.
*/
*/
-function generateSlug(db, { secret = false } = {}) {
+async function generateSlug(db, { secret = false } = {}) {
const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');
const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');
const words = () => `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}`;
const words = () => `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}`;
if (secret) {
if (secret) {
@@ -69,18 +69,18 @@ function generateSlug(db, { secret = false } = {}) {
@@ -69,18 +69,18 @@ function generateSlug(db, { secret = false } = {}) {
let tail = '';
let tail = '';
for (let j = 0; j < SECRET_CHARS; j++) tail += SECRET_ALPHABET[crypto.randomInt(SECRET_ALPHABET.length)];
for (let j = 0; j < SECRET_CHARS; j++) tail += SECRET_ALPHABET[crypto.randomInt(SECRET_ALPHABET.length)];
const slug = `${words()}-${tail}`;
const slug = `${words()}-${tail}`;
- if (!taken.get(slug)) return slug;
+ if (!await taken.get(slug)) return slug;
}
}
throw new Error('Could not generate a unique paste slug');
throw new Error('Could not generate a unique paste slug');
}
}
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 10; i++) {
const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(10, 100)}`;
const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(10, 100)}`;
- if (!taken.get(slug)) return slug;
+ if (!await taken.get(slug)) return slug;
}
}
// ~180k combinations; if ten draws all collide, widen the number rather than fail.
// ~180k combinations; if ten draws all collide, widen the number rather than fail.
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 10; i++) {
const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, 10000)}`;
const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, 10000)}`;
- if (!taken.get(slug)) return slug;
+ if (!await taken.get(slug)) return slug;
}
}
throw new Error('Could not generate a unique paste slug');
throw new Error('Could not generate a unique paste slug');
}
}
@@ -111,12 +111,12 @@ const likeEscape = (s) => String(s).replace(/[\\%_]/g, (c) => `\\${c}`);
@@ -111,12 +111,12 @@ const likeEscape = (s) => String(s).replace(/[\\%_]/g, (c) => `\\${c}`);
// ── Pastes ───────────────────────────────────────────────────
// ── Pastes ───────────────────────────────────────────────────
-function getBySlug(db, slug) {
- return db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug)) || null;
+async function getBySlug(db, slug) {
+ return await db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug)) || null;
}
}
-function getById(db, id) {
- return db.prepare('SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL').get(id) || null;
+async function getById(db, id) {
+ return await db.prepare('SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL').get(id) || null;
}
}
const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',
const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',
@@ -124,15 +124,15 @@ const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'con
@@ -124,15 +124,15 @@ const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'con
'ai_summary', 'ai_tags', 'ai_analyzed_at'];
'ai_summary', 'ai_tags', 'ai_analyzed_at'];
/** Insert a new paste (fields already validated); returns the stored row. */
/** Insert a new paste (fields already validated); returns the stored row. */
-function insertPaste(db, fields) {
+async function insertPaste(db, fields) {
const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);
const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);
- return db.transaction(() => {
- const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)
+ return await db.tx(async () => {
+ const info = await db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')}) RETURNING id`)
.run(...cols.map((c) => fields[c]));
.run(...cols.map((c) => fields[c]));
- const row = getById(db, info.lastInsertRowid);
- require('../events').pasteCreated(row); // community.paste.created, in this transaction
+ const row = await getById(db, info.lastInsertRowid);
+ await require('../events').pasteCreated(row); // community.paste.created, in this transaction
return row;
return row;
- })();
+ });
}
}
/**
/**
@@ -141,9 +141,9 @@ function insertPaste(db, fields) {
@@ -141,9 +141,9 @@ function insertPaste(db, fields) {
* edit also snapshots the original as revision 1, so the history is complete without storing a
* edit also snapshots the original as revision 1, so the history is complete without storing a
* copy of every paste that is never edited.
* copy of every paste that is never edited.
*/
*/
-function updatePaste(db, id, patch, editedBy = null) {
- return db.transaction(() => {
- const cur = getById(db, id);
+async function updatePaste(db, id, patch, editedBy = null) {
+ return await db.tx(async () => {
+ const cur = await getById(db, id);
if (!cur) return null;
if (!cur) return null;
const sets = [];
const sets = [];
const params = [];
const params = [];
@@ -154,39 +154,39 @@ function updatePaste(db, id, patch, editedBy = null) {
@@ -154,39 +154,39 @@ function updatePaste(db, id, patch, editedBy = null) {
}
}
const textChanged = ['title', 'content', 'language'].some((k) => patch[k] !== undefined && patch[k] !== cur[k]);
const textChanged = ['title', 'content', 'language'].some((k) => patch[k] !== undefined && patch[k] !== cur[k]);
if (textChanged) {
if (textChanged) {
- const hasCur = db.prepare('SELECT 1 FROM paste_versions WHERE paste_id = ? AND revision = ?').get(id, cur.revision);
+ const hasCur = await db.prepare('SELECT 1 FROM paste_versions WHERE paste_id = ? AND revision = ?').get(id, cur.revision);
if (!hasCur) {
if (!hasCur) {
- db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, cur.revision, cur.title, cur.content, cur.language, cur.owner_subject, cur.updated_at || cur.created_at);
.run(id, cur.revision, cur.title, cur.content, cur.language, cur.owner_subject, cur.updated_at || cur.created_at);
}
}
sets.push('revision = revision + 1');
sets.push('revision = revision + 1');
}
}
- sets.push('updated_at = CURRENT_TIMESTAMP');
- db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
- const next = getById(db, id);
+ sets.push('updated_at = ov_now()');
+ await db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
+ const next = await getById(db, id);
if (textChanged) {
if (textChanged) {
- db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, next.revision, next.title, next.content, next.language, editedBy);
.run(id, next.revision, next.title, next.content, next.language, editedBy);
}
}
const changed = ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].filter((k) => patch[k] !== undefined && patch[k] !== cur[k]);
const changed = ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].filter((k) => patch[k] !== undefined && patch[k] !== cur[k]);
- if (changed.length) require('../events').pasteUpdated(next, changed);
+ if (changed.length) await require('../events').pasteUpdated(next, changed);
return next;
return next;
- })();
+ });
}
}
-function listVersions(db, pasteId) {
- return db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);
+async function listVersions(db, pasteId) {
+ return await db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);
}
}
/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
-function softDelete(db, id) {
- return db.transaction(() => {
- const cur = getById(db, id);
- const n = db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,
- ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;
- if (n && cur) require('../events').pasteDeleted(cur.slug); // community.paste.deleted
+async function softDelete(db, id) {
+ return await db.tx(async () => {
+ const cur = await getById(db, id);
+ const n = (await db.prepare(`UPDATE pastes SET deleted_at = ov_now(), content = NULL, screenshot_url = NULL, metadata = NULL,
+ ai_summary = NULL, ai_tags = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL`).run(id)).changes;
+ if (n && cur) await require('../events').pasteDeleted(cur.slug); // community.paste.deleted
return n;
return n;
- })();
+ });
}
}
/**
/**
@@ -203,7 +203,7 @@ function softDelete(db, id) {
@@ -203,7 +203,7 @@ function softDelete(db, id) {
* with others by date or score turn it off so the order is the sort alone
* with others by date or score turn it off so the order is the sort alone
* Returns { rows, total }.
* Returns { rows, total }.
*/
*/
-function listPastes(db, opts = {}) {
+async function listPastes(db, opts = {}) {
const where = ['deleted_at IS NULL'];
const where = ['deleted_at IS NULL'];
const params = [];
const params = [];
// The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.
// The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.
@@ -216,7 +216,7 @@ function listPastes(db, opts = {}) {
@@ -216,7 +216,7 @@ function listPastes(db, opts = {}) {
if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
if (opts.search) {
if (opts.search) {
const q = `%${likeEscape(opts.search)}%`;
const q = `%${likeEscape(opts.search)}%`;
- where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");
+ where.push("(title ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')");
params.push(q, q);
params.push(q, q);
}
}
// datetime() on both sides: imported rows may carry ISO text, which must compare by time.
// datetime() on both sides: imported rows may carry ISO text, which must compare by time.
@@ -229,107 +229,107 @@ function listPastes(db, opts = {}) {
@@ -229,107 +229,107 @@ function listPastes(db, opts = {}) {
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);
const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
const clause = where.join(' AND ');
const clause = where.join(' AND ');
- const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY ${order} LIMIT ? OFFSET ?`)
+ const rows = await db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY ${order} LIMIT ? OFFSET ?`)
.all(...params, limit, offset);
.all(...params, limit, offset);
- const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);
+ const { total } = await db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);
return { rows, total };
return { rows, total };
}
}
/** Pastes created by a subject since `sinceSql` (an SQLite datetime modifier, e.g. '-1 day'). */
/** Pastes created by a subject since `sinceSql` (an SQLite datetime modifier, e.g. '-1 day'). */
-function countOwnerSince(db, subject, sinceSql) {
- return db.prepare("SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;
+async function countOwnerSince(db, subject, sinceSql) {
+ return (await db.prepare("SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql)).c;
}
}
/** ms timestamp of the subject's newest paste (deleted ones count: deleting doesn't reset a cooldown). */
/** ms timestamp of the subject's newest paste (deleted ones count: deleting doesn't reset a cooldown). */
-function lastPasteTime(db, subject) {
- const row = db.prepare('SELECT created_at FROM pastes WHERE owner_subject = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(subject);
+async function lastPasteTime(db, subject) {
+ const row = await db.prepare('SELECT created_at FROM pastes WHERE owner_subject = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(subject);
return row ? Date.parse(String(row.created_at).replace(' ', 'T') + 'Z') : 0;
return row ? Date.parse(String(row.created_at).replace(' ', 'T') + 'Z') : 0;
}
}
-function hasLiked(db, pasteId, subject) {
- return !!db.prepare('SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?').get(pasteId, subject);
+async function hasLiked(db, pasteId, subject) {
+ return !!await db.prepare('SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?').get(pasteId, subject);
}
}
/** Toggle a like; the counter is recounted from the rows (as Media did). → { liked, likes } */
/** Toggle a like; the counter is recounted from the rows (as Media did). → { liked, likes } */
-function toggleLike(db, pasteId, subject) {
- return db.transaction(() => {
- const already = hasLiked(db, pasteId, subject);
- if (already) db.prepare('DELETE FROM paste_likes WHERE paste_id = ? AND subject_id = ?').run(pasteId, subject);
- else db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id) VALUES (?, ?)').run(pasteId, subject);
- db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(pasteId, pasteId);
- return { liked: !already, likes: db.prepare('SELECT likes FROM pastes WHERE id = ?').get(pasteId).likes };
- })();
+async function toggleLike(db, pasteId, subject) {
+ return await db.tx(async () => {
+ const already = await hasLiked(db, pasteId, subject);
+ if (already) await db.prepare('DELETE FROM paste_likes WHERE paste_id = ? AND subject_id = ?').run(pasteId, subject);
+ else await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (?, ?) ON CONFLICT DO NOTHING').run(pasteId, subject);
+ await db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(pasteId, pasteId);
+ return { liked: !already, likes: (await db.prepare('SELECT likes FROM pastes WHERE id = ?').get(pasteId)).likes };
+ });
}
}
-function incrementCopies(db, pasteId) {
- db.prepare('UPDATE pastes SET copies = copies + 1 WHERE id = ?').run(pasteId);
- return db.prepare('SELECT copies FROM pastes WHERE id = ?').get(pasteId).copies;
+async function incrementCopies(db, pasteId) {
+ await db.prepare('UPDATE pastes SET copies = copies + 1 WHERE id = ?').run(pasteId);
+ return (await db.prepare('SELECT copies FROM pastes WHERE id = ?').get(pasteId)).copies;
}
}
/** Burn-after-read pastes keep a literal every-read counter. Returns the new count. */
/** Burn-after-read pastes keep a literal every-read counter. Returns the new count. */
-function bumpViews(db, pasteId) {
- db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
- return db.prepare('SELECT views FROM pastes WHERE id = ?').get(pasteId).views;
+async function bumpViews(db, pasteId) {
+ await db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
+ return (await db.prepare('SELECT views FROM pastes WHERE id = ?').get(pasteId)).views;
}
}
/**
/**
* Visit-based view counting (Media's rules): a visitor's first visit counts as a view and a
* Visit-based view counting (Media's rules): a visitor's first visit counts as a view and a
* unique view; later visits count again only after `cooldownSec`. → { counted, unique, views, unique_views }
* unique view; later visits count again only after `cooldownSec`. → { counted, unique, views, unique_views }
*/
*/
-function recordVisit(db, pasteId, visitor, cooldownSec) {
- return db.transaction(() => {
- const row = db.prepare('SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?').get(pasteId, visitor);
+async function recordVisit(db, pasteId, visitor, cooldownSec) {
+ return await db.tx(async () => {
+ const row = await db.prepare('SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?').get(pasteId, visitor);
let counted = false, unique = false;
let counted = false, unique = false;
if (!row) {
if (!row) {
- db.prepare('INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)').run(pasteId, visitor);
- db.prepare('UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?').run(pasteId);
+ await db.prepare('INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)').run(pasteId, visitor);
+ await db.prepare('UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?').run(pasteId);
counted = true; unique = true;
counted = true; unique = true;
} else {
} else {
const lastMs = Date.parse(String(row.last_at).replace(' ', 'T') + 'Z');
const lastMs = Date.parse(String(row.last_at).replace(' ', 'T') + 'Z');
if (!(cooldownSec > 0 && Date.now() - lastMs < cooldownSec * 1000)) {
if (!(cooldownSec > 0 && Date.now() - lastMs < cooldownSec * 1000)) {
- db.prepare('UPDATE paste_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE paste_id = ? AND visitor = ?').run(pasteId, visitor);
- db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
+ await db.prepare('UPDATE paste_visits SET last_at = ov_now(), visits = visits + 1 WHERE paste_id = ? AND visitor = ?').run(pasteId, visitor);
+ await db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
counted = true;
counted = true;
}
}
}
}
- const c = db.prepare('SELECT views, unique_views FROM pastes WHERE id = ?').get(pasteId);
+ const c = await db.prepare('SELECT views, unique_views FROM pastes WHERE id = ?').get(pasteId);
return { counted, unique, views: c.views, unique_views: c.unique_views };
return { counted, unique, views: c.views, unique_views: c.unique_views };
- })();
+ });
}
}
-function pruneVisits(db, days = 30) {
- return db.prepare("DELETE FROM paste_visits WHERE last_at < datetime('now', ?)").run(`-${days} days`).changes;
+async function pruneVisits(db, days = 30) {
+ return (await db.prepare("DELETE FROM paste_visits WHERE last_at < datetime('now', ?)").run(`-${days} days`)).changes;
}
}
-function setAi(db, pasteId, summary, tags) {
- db.prepare('UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = CURRENT_TIMESTAMP WHERE id = ?').run(summary, tags, pasteId);
- return getById(db, pasteId);
+async function setAi(db, pasteId, summary, tags) {
+ await db.prepare('UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = ov_now() WHERE id = ?').run(summary, tags, pasteId);
+ return await getById(db, pasteId);
}
}
-function setScreenshot(db, pasteId, url, mediaRef) {
- db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(url, mediaRef, pasteId);
- return getById(db, pasteId);
+async function setScreenshot(db, pasteId, url, mediaRef) {
+ await db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, updated_at = ov_now() WHERE id = ?').run(url, mediaRef, pasteId);
+ return await getById(db, pasteId);
}
}
-function setVisibility(db, pasteId, visibility) {
- return db.transaction(() => {
- const cur = getById(db, pasteId);
- const r = db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);
- if (r.changes && cur && cur.visibility !== visibility) require('../events').pasteUpdated(getById(db, pasteId), ['visibility']);
+async function setVisibility(db, pasteId, visibility) {
+ return await db.tx(async () => {
+ const cur = await getById(db, pasteId);
+ const r = await db.prepare('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);
+ if (r.changes && cur && cur.visibility !== visibility) await require('../events').pasteUpdated(await getById(db, pasteId), ['visibility']);
return r.changes;
return r.changes;
- })();
+ });
}
}
/** Media's admin stats shape. */
/** Media's admin stats shape. */
-function stats(db) {
- const r = db.prepare(`
+async function stats(db) {
+ const r = await db.prepare(`
SELECT COUNT(*) AS total,
SELECT COUNT(*) AS total,
- SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS textPastes,
- SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots,
- SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks,
- COALESCE(SUM(views), 0) AS totalViews,
- COALESCE(SUM(copies), 0) AS totalCopies,
- COALESCE(SUM(likes), 0) AS totalLikes
+ COUNT(*) FILTER (WHERE type = 'paste') AS "textPastes",
+ COUNT(*) FILTER (WHERE type = 'screenshot') AS screenshots,
+ COUNT(*) FILTER (WHERE forked_from IS NOT NULL) AS forks,
+ COALESCE(SUM(views), 0)::bigint AS "totalViews",
+ COALESCE(SUM(copies), 0)::bigint AS "totalCopies",
+ COALESCE(SUM(likes), 0)::bigint AS "totalLikes"
FROM pastes WHERE deleted_at IS NULL`).get() || {};
FROM pastes WHERE deleted_at IS NULL`).get() || {};
return {
return {
total: r.total || 0, textPastes: r.textPastes || 0, screenshots: r.screenshots || 0, forks: r.forks || 0,
total: r.total || 0, textPastes: r.textPastes || 0, screenshots: r.screenshots || 0, forks: r.forks || 0,
@@ -337,72 +337,72 @@ function stats(db) {
@@ -337,72 +337,72 @@ function stats(db) {
};
};
}
}
-function listForks(db, limit, offset) {
- const forks = db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at
+async function listForks(db, limit, offset) {
+ const forks = await db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at
FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL
FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(limit, offset);
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(limit, offset);
- const total = db.prepare('SELECT COUNT(*) AS c FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').get().c;
+ const total = (await db.prepare('SELECT COUNT(*) AS c FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').get()).c;
return { forks, total };
return { forks, total };
}
}
-function deleteAllForks(db) {
- return db.transaction(() => {
- const ids = db.prepare('SELECT id FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').all().map((r) => r.id);
- for (const id of ids) softDelete(db, id);
+async function deleteAllForks(db) {
+ return await db.tx(async () => {
+ const ids = (await db.prepare('SELECT id FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').all()).map((r) => r.id);
+ for (const id of ids) await softDelete(db, id);
return ids.length;
return ids.length;
- })();
+ });
}
}
// ── Comments ─────────────────────────────────────────────────
// ── Comments ─────────────────────────────────────────────────
-function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {
- const info = db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?)')
+async function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {
+ const info = await db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?) RETURNING id')
.run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);
.run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);
- return getComment(db, info.lastInsertRowid);
+ return await getComment(db, info.lastInsertRowid);
}
}
-function getComment(db, id) {
- return db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;
+async function getComment(db, id) {
+ return await db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;
}
}
/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */
/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */
-function listComments(db, pasteId, limit = 50, offset = 0) {
- const top = db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
+async function listComments(db, pasteId, limit = 50, offset = 0) {
+ const top = await db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);
const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');
const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');
- for (const c of top) { c.replies = replies.all(c.id); c.reply_count = c.replies.length; }
+ for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }
return top;
return top;
}
}
-function countComments(db, pasteId) {
- return db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId).c;
+async function countComments(db, pasteId) {
+ return (await db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId)).c;
}
}
-function softDeleteComment(db, id) {
- return db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id).changes;
+async function softDeleteComment(db, id) {
+ return (await db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = ov_now() WHERE id = ?').run(id)).changes;
}
}
// ── Subject projection (display cache — never authority) ─────
// ── Subject projection (display cache — never authority) ─────
-function getProjections(db, subjectIds) {
+async function getProjections(db, subjectIds) {
const out = new Map();
const out = new Map();
const ids = [...new Set((subjectIds || []).filter(Boolean))];
const ids = [...new Set((subjectIds || []).filter(Boolean))];
const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');
const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');
- for (const id of ids) { const r = stmt.get(id); if (r) out.set(id, r); }
+ for (const id of ids) { const r = await stmt.get(id); if (r) out.set(id, r); }
return out;
return out;
}
}
/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */
/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */
-function upsertProjection(db, p) {
+async function upsertProjection(db, p) {
if (!p || !p.subject_id) return;
if (!p || !p.subject_id) return;
- db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)
- VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, CURRENT_TIMESTAMP)
+ await db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)
+ VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, ov_now())
ON CONFLICT(subject_id) DO UPDATE SET
ON CONFLICT(subject_id) DO UPDATE SET
- username = COALESCE(excluded.username, username),
- display_name = COALESCE(excluded.display_name, display_name),
- avatar_url = COALESCE(excluded.avatar_url, avatar_url),
- profile_color = COALESCE(excluded.profile_color, profile_color),
- refreshed_at = CURRENT_TIMESTAMP`)
+ username = COALESCE(excluded.username, subject_projection.username),
+ display_name = COALESCE(excluded.display_name, subject_projection.display_name),
+ avatar_url = COALESCE(excluded.avatar_url, subject_projection.avatar_url),
+ profile_color = COALESCE(excluded.profile_color, subject_projection.profile_color),
+ refreshed_at = ov_now()`)
.run({
.run({
subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,
subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,
avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,
avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,
@@ -410,20 +410,20 @@ function upsertProjection(db, p) {
@@ -410,20 +410,20 @@ function upsertProjection(db, p) {
}
}
/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */
/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */
-function subjectsByUsername(db, username) {
- return db.prepare('SELECT subject_id FROM subject_projection WHERE username = ? COLLATE NOCASE ORDER BY refreshed_at DESC')
- .all(String(username)).map((r) => r.subject_id);
+async function subjectsByUsername(db, username) {
+ return (await db.prepare('SELECT subject_id FROM subject_projection WHERE lower(username) = lower(?) ORDER BY refreshed_at DESC')
+ .all(String(username))).map((r) => r.subject_id);
}
}
// ── Legacy id map ────────────────────────────────────────────
// ── Legacy id map ────────────────────────────────────────────
-function mapGet(db, system, type, id) {
- return db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
+async function mapGet(db, system, type, id) {
+ return await db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
.get(String(system), String(type), String(id)) || null;
.get(String(system), String(type), String(id)) || null;
}
}
-function mapSet(db, system, type, id, targetType, targetId) {
- db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)
+async function mapSet(db, system, type, id, targetType, targetId) {
+ await db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`)
ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`)
.run(String(system), String(type), String(id), String(targetType), String(targetId));
.run(String(system), String(type), String(id), String(targetType), String(targetId));
}
}
@@ -28,9 +28,9 @@ function createPulseApi({ pulse, viewers, limits }) {
@@ -28,9 +28,9 @@ function createPulseApi({ pulse, viewers, limits }) {
const servicesOnly = (req, res, next) => (req.viewer.kind === 'service' ? next()
const servicesOnly = (req, res, next) => (req.viewer.kind === 'service' ? next()
: contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `a service token with ${WRITE} is required`, ctx: req.ov }));
: contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `a service token with ${WRITE} is required`, ctx: req.ov }));
- router.get('/', run((req) => pulse.list(req.query)));
- router.post('/items', servicesOnly, serviceCap(WRITE), ingest, jsonBody, run((req) => pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
- router.delete('/items/:service/:type/:id', servicesOnly, serviceCap(WRITE), ingest, run((req) => pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));
+ router.get('/', run(async (req) => await pulse.list(req.query)));
+ router.post('/items', servicesOnly, serviceCap(WRITE), ingest, jsonBody, run(async (req) => await pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
+ router.delete('/items/:service/:type/:id', servicesOnly, serviceCap(WRITE), ingest, run(async (req) => await pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
* that go down or stay put announce nothing; only the public fields are read.
* that go down or stay put announce nothing; only the public fields are read.
*
*
* And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token
* And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token
- * cutoff (openvibe-sdk createRevocationStore); the viewer resolver refuses their older tokens.
+ * cutoff (openvibe-sdk createPgRevocationStore); the viewer resolver refuses their older tokens.
*
*
* And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only
* And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only
* answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.
* answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
*/
*/
const express = require('express');
const express = require('express');
const { http, serviceAuth } = require('openvibe-contracts');
const { http, serviceAuth } = require('openvibe-contracts');
-const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const store = require('./store');
const store = require('./store');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
@@ -88,25 +88,25 @@ function gameProgressOf(event) {
@@ -88,25 +88,25 @@ function gameProgressOf(event) {
}
}
/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */
/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */
-function applyGameProgress(db, g) {
- const prev = db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);
- db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)
- ON CONFLICT(subject_id) DO UPDATE SET level = MAX(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);
+async function applyGameProgress(db, g) {
+ const prev = await db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);
+ await db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)
+ ON CONFLICT(subject_id) DO UPDATE SET level = GREATEST(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);
if (!prev) return 'games:baseline';
if (!prev) return 'games:baseline';
const milestone = Math.floor(g.level / GAME_MILESTONE) * GAME_MILESTONE;
const milestone = Math.floor(g.level / GAME_MILESTONE) * GAME_MILESTONE;
if (milestone <= prev.level || milestone < GAME_MILESTONE) return 'games:no_milestone';
if (milestone <= prev.level || milestone < GAME_MILESTONE) return 'games:no_milestone';
- const r = store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,
+ const r = await store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,
url: GAME_URL, actor_subject: g.subject, origin: 'user', occurred_at: g.at });
url: GAME_URL, actor_subject: g.subject, origin: 'user', occurred_at: g.at });
return r.created ? 'pulse:created' : 'pulse:updated';
return r.created ? 'pulse:created' : 'pulse:updated';
}
}
function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {
function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
- const inbox = createInbox(db, { table: 'community_event_inbox', now });
- inbox.ensureSchema();
+ // Receipts (community_event_inbox) are in migrations/0001_initial.sql.
+ const inbox = createPgInbox(db, { table: 'community_event_inbox', now });
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
const router = express.Router();
const router = express.Router();
- router.post('/', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
const ctx = http.requestContext(req.headers);
const ctx = http.requestContext(req.headers);
const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'community.internal_only', 'internal route');
if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'community.internal_only', 'internal route');
@@ -120,7 +120,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -120,7 +120,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
stats.received++; stats.last_at = new Date(now()).toISOString();
stats.received++; stats.last_at = new Date(now()).toISOString();
if (event.event_type === 'network.user.token_valid_after') {
if (event.event_type === 'network.user.token_valid_after') {
if (!revocations) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_store' }); }
if (!revocations) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_store' }); }
- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: revocations.apply(event) }));
+ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await revocations.apply(event) }));
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
}
}
@@ -128,7 +128,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -128,7 +128,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
const p = blocks.payloadOf(event);
const p = blocks.payloadOf(event);
if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
try {
try {
- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: blocks.apply(db, p, now()) }));
+ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await blocks.apply(db, p, now()) }));
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
} catch (err) {
} catch (err) {
@@ -156,7 +156,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -156,7 +156,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
const p = merge.payloadOf(event);
const p = merge.payloadOf(event);
if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
if (typeof p === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: p }); }
try {
try {
- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: merge.apply(db, p, { log }) }));
+ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await merge.apply(db, p, { log }) }));
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
} catch (err) {
} catch (err) {
@@ -167,7 +167,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -167,7 +167,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
}
}
if (event.event_type === 'vip.membership.changed') {
if (event.event_type === 'vip.membership.changed') {
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
+ const r = await inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
}
}
@@ -175,7 +175,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -175,7 +175,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
const g = gameProgressOf(event);
const g = gameProgressOf(event);
if (typeof g === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: g }); }
if (typeof g === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: g }); }
try {
try {
- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: applyGameProgress(db, g) }));
+ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await applyGameProgress(db, g) }));
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
} catch (err) {
} catch (err) {
@@ -187,7 +187,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -187,7 +187,7 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
const item = itemFor(event);
const item = itemFor(event);
if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }
if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }
try {
try {
- const r = inbox.once(CONSUMER, event.event_id, () => ({ outcome: store.upsertItem(db, item).created ? 'pulse:created' : 'pulse:updated' }));
+ const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: (await store.upsertItem(db, item)).created ? 'pulse:created' : 'pulse:updated' }));
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
} catch (err) {
} catch (err) {
@@ -33,18 +33,18 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -33,18 +33,18 @@ function createPulse({ db, network = null, config = {} } = {}) {
const base = (config.baseUrl || '').replace(/\/$/, '');
const base = (config.baseUrl || '').replace(/\/$/, '');
/** Record one of Community's own items; never lets a Pulse problem fail the write that caused it. */
/** Record one of Community's own items; never lets a Pulse problem fail the write that caused it. */
- function recordLocal(type, id, { title, path, actor = null, origin = 'user', at = null }) {
+ async function recordLocal(type, id, { title, path, actor = null, origin = 'user', at = null }) {
try {
try {
const o = ORIGINS.includes(origin) ? origin : 'user';
const o = ORIGINS.includes(origin) ? origin : 'user';
- store.upsertItem(db, {
+ await store.upsertItem(db, {
source_service: 'community', source_type: type, source_id: String(id),
source_service: 'community', source_type: type, source_id: String(id),
title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,
title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,
actor_subject: o === 'user' ? actor : null, origin: o, occurred_at: at || sqlTime(),
actor_subject: o === 'user' ? actor : null, origin: o, occurred_at: at || sqlTime(),
});
});
} catch (err) { console.warn('[Pulse] record failed:', err.message); }
} catch (err) { console.warn('[Pulse] record failed:', err.message); }
}
}
- function forgetLocal(type, id) {
- try { store.removeItem(db, 'community', type, id); } catch (err) { console.warn('[Pulse] remove failed:', err.message); }
+ async function forgetLocal(type, id) {
+ try { await store.removeItem(db, 'community', type, id); } catch (err) { console.warn('[Pulse] remove failed:', err.message); }
}
}
function shape(i, projections) {
function shape(i, projections) {
@@ -66,31 +66,31 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -66,31 +66,31 @@ function createPulse({ db, network = null, config = {} } = {}) {
ORIGINS,
ORIGINS,
// ── hooks for Community's own writes ─────────────────
// ── hooks for Community's own writes ─────────────────
- pasteCreated(p) {
+ async pasteCreated(p) {
if (!p || p.visibility !== 'public' || p.origin !== 'user' || Number(p.burn_after_read) || Number(p.is_nsfw)) return;
if (!p || p.visibility !== 'public' || p.origin !== 'user' || Number(p.burn_after_read) || Number(p.is_nsfw)) return;
- recordLocal('paste', p.slug, { title: p.title, path: `/p/${encodeURIComponent(p.slug)}`, actor: p.owner_subject, origin: 'user', at: p.created_at });
+ await recordLocal('paste', p.slug, { title: p.title, path: `/p/${encodeURIComponent(p.slug)}`, actor: p.owner_subject, origin: 'user', at: p.created_at });
},
},
- pasteChanged(p) {
+ async pasteChanged(p) {
if (!p) return;
if (!p) return;
- if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) forgetLocal('paste', p.slug);
+ if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) await forgetLocal('paste', p.slug);
},
},
- pasteGone(slug) { forgetLocal('paste', slug); },
- threadCreated(thread, space) {
+ async pasteGone(slug) { await forgetLocal('paste', slug); },
+ async threadCreated(thread, space) {
if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
- recordLocal('thread', thread.id, {
+ await recordLocal('thread', thread.id, {
title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,
title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,
actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
});
});
},
},
- postCreated(post, thread, space) {
+ async postCreated(post, thread, space) {
if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
- recordLocal('post', post.id, {
+ await recordLocal('post', post.id, {
title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,
title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
});
});
},
},
- threadGone(id) { forgetLocal('thread', id); },
- postGone(id) { forgetLocal('post', id); },
+ async threadGone(id) { await forgetLocal('thread', id); },
+ async postGone(id) { await forgetLocal('post', id); },
/** POST /api/v1/pulse/items (a service with community.pulse.write). → { item, created } */
/** POST /api/v1/pulse/items (a service with community.pulse.write). → { item, created } */
async ingest(v, body = {}) {
async ingest(v, body = {}) {
@@ -110,7 +110,7 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -110,7 +110,7 @@ function createPulse({ db, network = null, config = {} } = {}) {
if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwise
if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwise
const when = body.occurred_at == null ? new Date() : new Date(body.occurred_at);
const when = body.occurred_at == null ? new Date() : new Date(body.occurred_at);
if (Number.isNaN(when.getTime()) || when.getTime() > Date.now() + FUTURE_SKEW_MS || when.getUTCFullYear() < 2000) fail(400, 'pulse.invalid_time', 'occurred_at must be an ISO time, not in the future');
if (Number.isNaN(when.getTime()) || when.getTime() > Date.now() + FUTURE_SKEW_MS || when.getUTCFullYear() < 2000) fail(400, 'pulse.invalid_time', 'occurred_at must be an ISO time, not in the future');
- const out = store.upsertItem(db, {
+ const out = await store.upsertItem(db, {
source_service: ref.service, source_type: ref.type, source_id: ref.id,
source_service: ref.service, source_type: ref.type, source_id: ref.id,
title, url: parsed.toString(),
title, url: parsed.toString(),
actor_subject: origin === 'user' ? (v.subject || null) : null,
actor_subject: origin === 'user' ? (v.subject || null) : null,
@@ -121,9 +121,9 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -121,9 +121,9 @@ function createPulse({ db, network = null, config = {} } = {}) {
},
},
/** DELETE /api/v1/pulse/items/:service/:type/:id — a service retracting its own item. */
/** DELETE /api/v1/pulse/items/:service/:type/:id — a service retracting its own item. */
- retract(v, service, type, id) {
+ async retract(v, service, type, id) {
if (service !== serviceNameOf(v.service)) fail(403, 'pulse.foreign_ref', 'A service retracts only its own items');
if (service !== serviceNameOf(v.service)) fail(403, 'pulse.foreign_ref', 'A service retracts only its own items');
- return { removed: store.removeItem(db, service, type, id) };
+ return { removed: await store.removeItem(db, service, type, id) };
},
},
/** GET /api/v1/pulse ?origin=user|ai|system&after=<cursor>&limit= → { items, next_cursor } */
/** GET /api/v1/pulse ?origin=user|ai|system&after=<cursor>&limit= → { items, next_cursor } */
@@ -132,7 +132,7 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -132,7 +132,7 @@ function createPulse({ db, network = null, config = {} } = {}) {
if (origin && !ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);
if (origin && !ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || PAGE, 1), 100);
const before = decodeCursor(q.after, 2);
const before = decodeCursor(q.after, 2);
- const { rows, hasMore } = store.listItems(db, { origin, before, limit });
+ const { rows, hasMore } = await store.listItems(db, { origin, before, limit });
const projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));
const projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));
const last = rows[rows.length - 1];
const last = rows[rows.length - 1];
return { items: rows.map((r) => shape(r, projections)), next_cursor: hasMore && last ? encodeCursor([last.occurred_at, last.id]) : null };
return { items: rows.map((r) => shape(r, projections)), next_cursor: hasMore && last ? encodeCursor([last.occurred_at, last.id]) : null };
@@ -9,22 +9,22 @@
@@ -9,22 +9,22 @@
*/
*/
/** → { item, created } */
/** → { item, created } */
-function upsertItem(db, it) {
+async function upsertItem(db, it) {
const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');
const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');
- return db.transaction(() => {
+ return await db.tx(async () => {
const key = [it.source_service, it.source_type, String(it.source_id)];
const key = [it.source_service, it.source_type, String(it.source_id)];
- if (find.get(...key)) {
- db.prepare('UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, ...key);
- return { item: find.get(...key), created: false };
+ if (await find.get(...key)) {
+ await db.prepare('UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, ...key);
+ return { item: await find.get(...key), created: false };
}
}
- db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
+ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at);
VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at);
- return { item: find.get(...key), created: true };
- })();
+ return { item: await find.get(...key), created: true };
+ });
}
}
-function removeItem(db, service, type, id) {
- return db.prepare('DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').run(service, type, String(id)).changes;
+async function removeItem(db, service, type, id) {
+ return (await db.prepare('DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').run(service, type, String(id))).changes;
}
}
/**
/**
@@ -35,11 +35,11 @@ function removeItem(db, service, type, id) {
@@ -35,11 +35,11 @@ function removeItem(db, service, type, id) {
* opts.before [occurred_at, id] of the last item already seen
* opts.before [occurred_at, id] of the last item already seen
* → { rows, hasMore }
* → { rows, hasMore }
*/
*/
-function listItems(db, { origin = null, before = null, limit = 30 } = {}) {
- const rows = db.prepare(`
+async function listItems(db, { origin = null, before = null, limit = 30 } = {}) {
+ const rows = await db.prepare(`
SELECT i.* FROM pulse_items i
SELECT i.* FROM pulse_items i
- WHERE (@origin IS NULL OR i.origin = @origin)
- AND (@at IS NULL OR i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id))
+ WHERE (@origin::text IS NULL OR i.origin = @origin)
+ AND (@at::text IS NULL OR i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id::bigint))
AND (i.source_service <> 'community'
AND (i.source_service <> 'community'
OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))
OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))
OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id
OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id
@@ -54,44 +54,44 @@ function createRelayApi({ relay, db, viewers, inbound = null, limits }) {
@@ -54,44 +54,44 @@ function createRelayApi({ relay, db, viewers, inbound = null, limits }) {
return out;
return out;
}
}
- router.get('/status', run(() => relay.status()));
- router.get('/deliveries', run((req) => {
+ router.get('/status', run(async () => await relay.status()));
+ router.get('/deliveries', run(async (req) => {
const status = DELIVERY_STATUSES.includes(req.query.status) ? req.query.status : null;
const status = DELIVERY_STATUSES.includes(req.query.status) ? req.query.status : null;
const action = ['create', 'edit', 'delete'].includes(req.query.action) ? req.query.action : null;
const action = ['create', 'edit', 'delete'].includes(req.query.action) ? req.query.action : null;
- return { enabled: relay.enabled, deliveries: relay.listDeliveries({ status, action, limit: intIn(req.query.limit, 50, 1, 200) }) };
+ return { enabled: relay.enabled, deliveries: await relay.listDeliveries({ status, action, limit: intIn(req.query.limit, 50, 1, 200) }) };
}));
}));
- router.post('/deliveries/:id/retry', manage, run((req) => {
- if (!relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');
+ router.post('/deliveries/:id/retry', manage, run(async (req) => {
+ if (!await relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');
return { ok: true };
return { ok: true };
}));
}));
- router.post('/deliveries/:id/drop', manage, run((req) => {
- if (!relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');
+ router.post('/deliveries/:id/drop', manage, run(async (req) => {
+ if (!await relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');
return { ok: true };
return { ok: true };
}));
}));
- router.get('/inbound', run((req) => ({
- enabled: !!inbound, failures: inbound ? inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],
+ router.get('/inbound', run(async (req) => ({
+ enabled: !!inbound, failures: inbound ? await inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],
})));
})));
- router.post('/inbound/:id/dismiss', manage, run((req) => {
- if (!inbound || !inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');
+ router.post('/inbound/:id/dismiss', manage, run(async (req) => {
+ if (!inbound || !await inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');
return { ok: true };
return { ok: true };
}));
}));
- router.get('/mappings', run(() => ({ enabled: relay.enabled, mappings: relay.listMappings() })));
- router.post('/mappings', manage, jsonBody, run((req) => {
+ router.get('/mappings', run(async () => ({ enabled: relay.enabled, mappings: await relay.listMappings() })));
+ router.post('/mappings', manage, jsonBody, run(async (req) => {
const b = req.body || {};
const b = req.body || {};
- const space = forumStore.getSpace(db, String(b.space || ''));
+ const space = await forumStore.getSpace(db, String(b.space || ''));
if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
const ref = String(b.webhook_url_ref || '');
const ref = String(b.webhook_url_ref || '');
if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');
if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');
const ids = discordIds(b);
const ids = discordIds(b);
- return { mapping: relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false, ...ids, inbound: b.inbound === undefined ? undefined : !!b.inbound }) };
+ return { mapping: await relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false, ...ids, inbound: b.inbound === undefined ? undefined : !!b.inbound }) };
}, 201));
}, 201));
- router.put('/mappings/:id', manage, jsonBody, run((req) => {
+ router.put('/mappings/:id', manage, jsonBody, run(async (req) => {
const b = req.body || {};
const b = req.body || {};
const fields = { ...discordIds(b) };
const fields = { ...discordIds(b) };
if (b.enabled !== undefined) fields.enabled = !!b.enabled;
if (b.enabled !== undefined) fields.enabled = !!b.enabled;
if (b.inbound !== undefined) fields.inbound = !!b.inbound;
if (b.inbound !== undefined) fields.inbound = !!b.inbound;
- const m = relay.updateMapping(idOf(req), fields);
+ const m = await relay.updateMapping(idOf(req), fields);
if (!m) fail(404, 'relay.mapping_not_found', 'No such mapping');
if (!m) fail(404, 'relay.mapping_not_found', 'No such mapping');
return { mapping: m };
return { mapping: m };
}));
}));
@@ -110,6 +110,8 @@ function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = g
@@ -110,6 +110,8 @@ function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = g
sock.addEventListener('error', () => { /* a close event follows */ });
sock.addEventListener('error', () => { /* a close event follows */ });
}
}
+ let dispatching = Promise.resolve();
+ let pendingDispatches = 0;
function onMessage(raw) {
function onMessage(raw) {
let p;
let p;
try { p = JSON.parse(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8')); } catch { return; }
try { p = JSON.parse(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8')); } catch { return; }
@@ -161,12 +163,13 @@ function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = g
@@ -161,12 +163,13 @@ function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = g
}
}
if (!DISPATCH.has(t)) return;
if (!DISPATCH.has(t)) return;
state.dispatched++;
state.dispatched++;
- try {
- const r = onDispatch(t, d || {}, { botUserId: state.bot_user_id });
- if (r && typeof r.catch === 'function') r.catch((err) => console.warn(`[Relay] inbound ${t} failed:`, err.message));
- } catch (err) {
- console.warn(`[Relay] inbound ${t} failed:`, err.message);
- }
+ // One at a time, in arrival order: a MESSAGE_UPDATE never overtakes its MESSAGE_CREATE (handling is async).
+ // Idle, the handler starts at once; otherwise the event waits behind the one in progress.
+ const ctx = { botUserId: state.bot_user_id };
+ const go = () => { try { return Promise.resolve(onDispatch(t, d || {}, ctx)); } catch (err) { return Promise.reject(err); } };
+ const run = pendingDispatches === 0 ? go() : dispatching.then(go);
+ pendingDispatches++;
+ dispatching = run.catch((err) => console.warn(`[Relay] inbound ${t} failed:`, err.message)).finally(() => { pendingDispatches--; });
}
}
function onClose(code, why) {
function onClose(code, why) {
@@ -56,7 +56,7 @@ const DELIVERY_STATUSES = ['pending', 'delivered', 'failed', 'dropped', 'skipped
@@ -56,7 +56,7 @@ const DELIVERY_STATUSES = ['pending', 'delivered', 'failed', 'dropped', 'skipped
const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');
const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');
/** webhookVars: exact allowed names, or null/empty for the DISCORD_WEBHOOK_* default. */
/** webhookVars: exact allowed names, or null/empty for the DISCORD_WEBHOOK_* default. */
-function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, now = () => Date.now(), webhookVars = null } = {}) {
+function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, kickMs = 250, now = () => Date.now(), webhookVars = null } = {}) {
const base = (config.baseUrl || '').replace(/\/$/, '');
const base = (config.baseUrl || '').replace(/\/$/, '');
const allowed = Array.isArray(webhookVars) && webhookVars.length ? new Set(webhookVars) : null;
const allowed = Array.isArray(webhookVars) && webhookVars.length ? new Set(webhookVars) : null;
/** May a mapping name this variable? */
/** May a mapping name this variable? */
@@ -64,6 +64,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -64,6 +64,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
let timer = null;
let timer = null;
let draining = null;
let draining = null;
let stopped = false;
let stopped = false;
+ let kickTimer = null;
// While the Events worker runs it queues every create from the event stream; the forum's direct
// While the Events worker runs it queues every create from the event stream; the forum's direct
// calls then queue nothing (the dedupe keys would make them harmless anyway).
// calls then queue nothing (the dedupe keys would make them harmless anyway).
let createsFromEvents = false;
let createsFromEvents = false;
@@ -79,11 +80,11 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -79,11 +80,11 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const insertStmt = db.prepare(`INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, action, dedupe_key, source, event_id, next_attempt_at)
const insertStmt = db.prepare(`INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, action, dedupe_key, source, event_id, next_attempt_at)
VALUES (@thread_id, @post_id, @mapping_id, @action, @key, @source, @event_id, @at) ON CONFLICT(dedupe_key) DO NOTHING`);
VALUES (@thread_id, @post_id, @mapping_id, @action, @key, @source, @event_id, @at) ON CONFLICT(dedupe_key) DO NOTHING`);
- function insert(row) {
- return insertStmt.run({ post_id: null, source: 'direct', event_id: null, ...row, at: sqlTime(now()) }).changes;
+ async function insert(row) {
+ return (await insertStmt.run({ post_id: null, source: 'direct', event_id: null, ...row, at: sqlTime(now()) })).changes;
}
}
- const outMappings = (spaceId) => db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1 ORDER BY id").all(spaceId);
- const mapRow = (mappingId, type, id) => db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND mapping_id = ? AND local_type = ? AND local_id = ?").get(mappingId, type, id) || null;
+ const outMappings = async (spaceId) => await db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1 ORDER BY id").all(spaceId);
+ const mapRow = async (mappingId, type, id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND mapping_id = ? AND local_type = ? AND local_id = ?").get(mappingId, type, id) || null;
/** Why this thread (and a post of it) may not leave the site, or null. */
/** Why this thread (and a post of it) may not leave the site, or null. */
function refusal(thread, space, post = null) {
function refusal(thread, space, post = null) {
@@ -99,13 +100,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -99,13 +100,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
* A new thread, for every enabled out-mapping of its space. source 'events' (the Events worker,
* A new thread, for every enabled out-mapping of its space. source 'events' (the Events worker,
* eventId) or 'direct' (the forum; ignored while the worker queues creates). → number queued
* eventId) or 'direct' (the forum; ignored while the worker queues creates). → number queued
*/
*/
- function enqueueThread(thread, space, { source = 'direct', eventId = null } = {}) {
+ async function enqueueThread(thread, space, { source = 'direct', eventId = null } = {}) {
if (!enabled || !thread || !space) return 0;
if (!enabled || !thread || !space) return 0;
if (source === 'direct' && createsFromEvents) return 0;
if (source === 'direct' && createsFromEvents) return 0;
if (!mayLeave(thread, space)) return 0;
if (!mayLeave(thread, space)) return 0;
let queued = 0;
let queued = 0;
- for (const m of outMappings(space.id)) {
- queued += insert({ thread_id: thread.id, mapping_id: m.id, action: 'create', key: keys.create('thread', thread.id, m.id), source, event_id: eventId });
+ for (const m of await outMappings(space.id)) {
+ queued += await insert({ thread_id: thread.id, mapping_id: m.id, action: 'create', key: keys.create('thread', thread.id, m.id), source, event_id: eventId });
}
}
if (queued) kick();
if (queued) kick();
return queued;
return queued;
@@ -115,15 +116,15 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -115,15 +116,15 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
* A new reply: to every mapping its thread went to (sent, or still waiting to be). The opening post
* A new reply: to every mapping its thread went to (sent, or still waiting to be). The opening post
* travels with its thread. → number queued
* travels with its thread. → number queued
*/
*/
- function enqueuePost(post, thread, space, { source = 'direct', eventId = null } = {}) {
+ async function enqueuePost(post, thread, space, { source = 'direct', eventId = null } = {}) {
if (!enabled || !post || !thread || !space || post.is_opening) return 0;
if (!enabled || !post || !thread || !space || post.is_opening) return 0;
if (source === 'direct' && createsFromEvents) return 0;
if (source === 'direct' && createsFromEvents) return 0;
if (!mayLeave(thread, space, post)) return 0;
if (!mayLeave(thread, space, post)) return 0;
let queued = 0;
let queued = 0;
- for (const m of outMappings(space.id)) {
- const parent = db.prepare("SELECT status FROM relay_deliveries WHERE dedupe_key = ? AND status IN ('pending', 'delivered')").get(keys.create('thread', thread.id, m.id));
- if (!parent && !mapRow(m.id, 'thread', thread.id)) continue;
- queued += insert({ thread_id: thread.id, post_id: post.id, mapping_id: m.id, action: 'create', key: keys.create('post', post.id, m.id), source, event_id: eventId });
+ for (const m of await outMappings(space.id)) {
+ const parent = await db.prepare("SELECT status FROM relay_deliveries WHERE dedupe_key = ? AND status IN ('pending', 'delivered')").get(keys.create('thread', thread.id, m.id));
+ if (!parent && !await mapRow(m.id, 'thread', thread.id)) continue;
+ queued += await insert({ thread_id: thread.id, post_id: post.id, mapping_id: m.id, action: 'create', key: keys.create('post', post.id, m.id), source, event_id: eventId });
}
}
if (queued) kick();
if (queued) kick();
return queued;
return queued;
@@ -133,14 +134,14 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -133,14 +134,14 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
* An edited post: the Discord message showing it (the thread's, for an opening post) is edited.
* An edited post: the Discord message showing it (the thread's, for an opening post) is edited.
* Only what is already on Discord; a create still waiting carries the new text anyway.
* Only what is already on Discord; a create still waiting carries the new text anyway.
*/
*/
- function enqueueEdit(post) {
+ async function enqueueEdit(post) {
if (!enabled || !post) return 0;
if (!enabled || !post) return 0;
const type = post.is_opening ? 'thread' : 'post';
const type = post.is_opening ? 'thread' : 'post';
const id = post.is_opening ? post.thread_id : post.id;
const id = post.is_opening ? post.thread_id : post.id;
- const rows = db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND direction = 'out' AND local_type = ? AND local_id = ? AND external_deleted_at IS NULL").all(type, id);
+ const rows = await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND direction = 'out' AND local_type = ? AND local_id = ? AND external_deleted_at IS NULL").all(type, id);
let queued = 0;
let queued = 0;
for (const r of rows) {
for (const r of rows) {
- queued += insert({ thread_id: post.thread_id, post_id: post.is_opening ? null : post.id, mapping_id: r.mapping_id, action: 'edit', key: keys.edit(type, id, r.mapping_id, post.revision) });
+ queued += await insert({ thread_id: post.thread_id, post_id: post.is_opening ? null : post.id, mapping_id: r.mapping_id, action: 'edit', key: keys.edit(type, id, r.mapping_id, post.revision) });
}
}
if (queued) kick();
if (queued) kick();
return queued;
return queued;
@@ -151,58 +152,66 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -151,58 +152,66 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
* moderated away or gated members-only: what is still waiting is skipped and what Discord shows is
* moderated away or gated members-only: what is still waiting is skipped and what Discord shows is
* deleted. Queued even while the relay is off, so it happens once it is on again.
* deleted. Queued even while the relay is off, so it happens once it is on again.
*/
*/
- function enqueueDelete(threadId, postId = null, reason = 'deleted') {
+ async function enqueueDelete(threadId, postId = null, reason = 'deleted') {
const note = `skipped: ${reason} before it was sent`;
const note = `skipped: ${reason} before it was sent`;
- return db.transaction(() => {
+ return await db.tx(async () => {
if (postId == null) {
if (postId == null) {
- db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE thread_id = ? AND status = 'pending' AND action <> 'delete'").run(note, threadId);
+ await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE thread_id = ? AND status = 'pending' AND action <> 'delete'").run(note, threadId);
} else {
} else {
- db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE post_id = ? AND status = 'pending' AND action <> 'delete'").run(note, postId);
+ await db.prepare("UPDATE relay_deliveries SET status = 'skipped', last_error = ?, updated_at = ov_now() WHERE post_id = ? AND status = 'pending' AND action <> 'delete'").run(note, postId);
}
}
const rows = postId == null
const rows = postId == null
- ? db.prepare("SELECT * FROM relay_message_map WHERE thread_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(threadId)
- : db.prepare("SELECT * FROM relay_message_map WHERE local_type = 'post' AND local_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(postId);
+ ? await db.prepare("SELECT * FROM relay_message_map WHERE thread_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(threadId)
+ : await db.prepare("SELECT * FROM relay_message_map WHERE local_type = 'post' AND local_id = ? AND direction = 'out' AND external_deleted_at IS NULL").all(postId);
let queued = 0;
let queued = 0;
for (const r of rows) {
for (const r of rows) {
- queued += insert({ thread_id: r.thread_id, post_id: r.local_type === 'post' ? r.local_id : null, mapping_id: r.mapping_id, action: 'delete', key: keys.remove(r.local_type, r.local_id, r.mapping_id) });
+ queued += await insert({ thread_id: r.thread_id, post_id: r.local_type === 'post' ? r.local_id : null, mapping_id: r.mapping_id, action: 'delete', key: keys.remove(r.local_type, r.local_id, r.mapping_id) });
}
}
if (queued && enabled) kick();
if (queued && enabled) kick();
return queued;
return queued;
- })();
+ });
}
}
/** Threads gated members-only (or otherwise no longer public): take them off Discord. */
/** Threads gated members-only (or otherwise no longer public): take them off Discord. */
- function hideThreads(threadIds, reason = 'made members-only') {
+ async function hideThreads(threadIds, reason = 'made members-only') {
let n = 0;
let n = 0;
- for (const id of threadIds) n += enqueueDelete(id, null, reason);
+ for (const id of threadIds) n += await enqueueDelete(id, null, reason);
return n;
return n;
}
}
- function kick() { setImmediate(() => { drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }); }
+ // A drain soon after the change commits (never before: it would not see the row), kickMs later so that changes made
+ // together (an edit and the delete right after it) go out as one pass, the edit skipped.
+ function kick() {
+ db.afterCommit(() => {
+ if (kickTimer || stopped) return;
+ kickTimer = setTimeout(() => { kickTimer = null; drain().catch((err) => console.warn('[Relay] drain failed:', err.message)); }, kickMs);
+ if (kickTimer.unref) kickTimer.unref();
+ });
+ }
// ── messages ─────────────────────────────────────────────
// ── messages ─────────────────────────────────────────────
- function authorName(subject, origin) {
+ async function authorName(subject, origin) {
if (origin === 'ai') return AI_DISPLAY_NAME;
if (origin === 'ai') return AI_DISPLAY_NAME;
if (origin === 'system' && !subject) return 'OpenVibe';
if (origin === 'system' && !subject) return 'OpenVibe';
if (subject) {
if (subject) {
- const p = pasteStore.getProjections(db, [subject]).get(subject);
+ const p = (await pasteStore.getProjections(db, [subject])).get(subject);
if (p) return p.display_name || p.username || 'someone';
if (p) return p.display_name || p.username || 'someone';
}
}
return 'someone';
return 'someone';
}
}
const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;
const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;
- function postUrl(post, thread, space) {
- const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;
+ async function postUrl(post, thread, space) {
+ const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
return `${threadUrl(thread, space)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}`;
return `${threadUrl(thread, space)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}`;
}
}
/** The thread's message: who started it, the title, an excerpt of the opening post, the link. */
/** The thread's message: who started it, the title, an excerpt of the opening post, the link. */
- function message(thread, space) {
- const opening = db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
+ async function message(thread, space) {
+ const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
const url = threadUrl(thread, space);
const url = threadUrl(thread, space);
return {
return {
username: 'OpenVibe.Community',
username: 'OpenVibe.Community',
- content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(authorName(thread.author_subject, thread.origin))}: <${url}>`,
+ content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,
allowed_mentions: { parse: [] },
allowed_mentions: { parse: [] },
embeds: [{
embeds: [{
title: String(thread.title).slice(0, 256),
title: String(thread.title).slice(0, 256),
@@ -215,12 +224,12 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -215,12 +224,12 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
}
}
/** A reply's message: who replied to which thread, an excerpt, the link to the post. */
/** A reply's message: who replied to which thread, an excerpt, the link to the post. */
- function postMessage(post, thread, space) {
- const url = postUrl(post, thread, space);
+ async function postMessage(post, thread, space) {
+ const url = await postUrl(post, thread, space);
const title = String(thread.title).slice(0, 200);
const title = String(thread.title).slice(0, 200);
return {
return {
username: 'OpenVibe.Community',
username: 'OpenVibe.Community',
- content: `${escapeDiscord(authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,
+ content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,
allowed_mentions: { parse: [] },
allowed_mentions: { parse: [] },
embeds: [{
embeds: [{
title: `Re: ${title}`.slice(0, 256),
title: `Re: ${title}`.slice(0, 256),
@@ -239,67 +248,67 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -239,67 +248,67 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
}
}
/** One send happened: delivered, retried later, or failed (the dead letter). */
/** One send happened: delivered, retried later, or failed (the dead letter). */
- function record(d, { ok, status = null, error = null, retry = false, retryAfter = 0, note = null }) {
+ async function record(d, { ok, status = null, error = null, retry = false, retryAfter = 0, note = null }) {
const attempts = d.attempts + 1;
const attempts = d.attempts + 1;
if (ok) {
if (ok) {
- db.prepare("UPDATE relay_deliveries SET status = 'delivered', attempts = ?, last_status = ?, last_error = ?, delivered_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
+ await db.prepare("UPDATE relay_deliveries SET status = 'delivered', attempts = ?, last_status = ?, last_error = ?, delivered_at = ov_now(), updated_at = ov_now() WHERE id = ?")
.run(attempts, status, note, d.id);
.run(attempts, status, note, d.id);
return 'delivered';
return 'delivered';
}
}
const giveUp = !retry || attempts >= maxAttempts;
const giveUp = !retry || attempts >= maxAttempts;
- db.prepare('UPDATE relay_deliveries SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+ await db.prepare('UPDATE relay_deliveries SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = ov_now() WHERE id = ?')
.run(giveUp ? 'failed' : 'pending', attempts, status, String(error || '').slice(0, 500), sqlTime(now() + (giveUp ? 0 : backoff(attempts, retryAfter))), d.id);
.run(giveUp ? 'failed' : 'pending', attempts, status, String(error || '').slice(0, 500), sqlTime(now() + (giveUp ? 0 : backoff(attempts, retryAfter))), d.id);
return giveUp ? 'failed' : 'retry';
return giveUp ? 'failed' : 'retry';
}
}
/** Nothing was sent: already done (delivered) or nothing left to do (skipped). */
/** Nothing was sent: already done (delivered) or nothing left to do (skipped). */
- function settle(d, status, reason) {
- db.prepare(`UPDATE relay_deliveries SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP,
- delivered_at = CASE WHEN ? = 'delivered' THEN CURRENT_TIMESTAMP ELSE delivered_at END WHERE id = ?`).run(status, reason, status, d.id);
+ async function settle(d, status, reason) {
+ await db.prepare(`UPDATE relay_deliveries SET status = ?, last_error = ?, updated_at = ov_now(),
+ delivered_at = CASE WHEN ? = 'delivered' THEN ov_now() ELSE delivered_at END WHERE id = ?`).run(status, reason, status, d.id);
return status === 'delivered' ? 'delivered' : 'skipped';
return status === 'delivered' ? 'delivered' : 'skipped';
}
}
/** Not yet (a reply waiting for its thread's message): later, without using an attempt. */
/** Not yet (a reply waiting for its thread's message): later, without using an attempt. */
- function defer(d, untilSql, reason) {
+ async function defer(d, untilSql, reason) {
const until = Math.max(Date.parse(`${String(untilSql || '').replace(' ', 'T')}Z`) || 0, now() + 1000);
const until = Math.max(Date.parse(`${String(untilSql || '').replace(' ', 'T')}Z`) || 0, now() + 1000);
- db.prepare('UPDATE relay_deliveries SET next_attempt_at = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(sqlTime(until), reason, d.id);
+ await db.prepare('UPDATE relay_deliveries SET next_attempt_at = ?, last_error = ?, updated_at = ov_now() WHERE id = ?').run(sqlTime(until), reason, d.id);
return 'retry';
return 'retry';
}
}
async function send(d) {
async function send(d) {
- const m = db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(d.mapping_id);
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(d.thread_id);
- const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
- const post = d.post_id ? forumStore.getPost(db, d.post_id) : null;
+ const m = await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(d.mapping_id);
+ const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(d.thread_id);
+ const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;
+ const post = d.post_id ? await forumStore.getPost(db, d.post_id) : null;
const type = d.post_id ? 'post' : 'thread';
const type = d.post_id ? 'post' : 'thread';
const localId = d.post_id || d.thread_id;
const localId = d.post_id || d.thread_id;
const gone = !thread || thread.deleted_at || !space || (d.post_id && (!post || post.deleted_at));
const gone = !thread || thread.deleted_at || !space || (d.post_id && (!post || post.deleted_at));
- const mapped = mapRow(d.mapping_id, type, localId);
+ const mapped = await mapRow(d.mapping_id, type, localId);
if (d.action === 'create') {
if (d.action === 'create') {
- if (gone) return settle(d, 'skipped', `the ${type} was deleted before it was sent`);
+ if (gone) return await settle(d, 'skipped', `the ${type} was deleted before it was sent`);
const why = refusal(thread, space, post);
const why = refusal(thread, space, post);
- if (why) return record(d, { ok: false, error: why });
- if (mapped) return settle(d, 'delivered', 'already on Discord');
- if (type === 'post' && !mapRow(d.mapping_id, 'thread', thread.id)) {
- const parent = db.prepare('SELECT status, next_attempt_at FROM relay_deliveries WHERE dedupe_key = ?').get(keys.create('thread', thread.id, d.mapping_id));
- if (parent && parent.status === 'pending') return defer(d, parent.next_attempt_at, "waiting for the thread's own message");
- return settle(d, 'skipped', 'the thread is not on Discord through this mapping');
+ if (why) return await record(d, { ok: false, error: why });
+ if (mapped) return await settle(d, 'delivered', 'already on Discord');
+ if (type === 'post' && !await mapRow(d.mapping_id, 'thread', thread.id)) {
+ const parent = await db.prepare('SELECT status, next_attempt_at FROM relay_deliveries WHERE dedupe_key = ?').get(keys.create('thread', thread.id, d.mapping_id));
+ if (parent && parent.status === 'pending') return await defer(d, parent.next_attempt_at, "waiting for the thread's own message");
+ return await settle(d, 'skipped', 'the thread is not on Discord through this mapping');
}
}
} else {
} else {
- if (!mapped || mapped.direction !== 'out') return settle(d, 'skipped', 'not on Discord');
- if (mapped.external_deleted_at) return settle(d, 'skipped', 'already deleted on Discord');
+ if (!mapped || mapped.direction !== 'out') return await settle(d, 'skipped', 'not on Discord');
+ if (mapped.external_deleted_at) return await settle(d, 'skipped', 'already deleted on Discord');
if (d.action === 'edit') {
if (d.action === 'edit') {
- if (gone) return settle(d, 'skipped', `the ${type} was deleted`);
+ if (gone) return await settle(d, 'skipped', `the ${type} was deleted`);
const why = refusal(thread, space, post);
const why = refusal(thread, space, post);
- if (why) return settle(d, 'skipped', why);
+ if (why) return await settle(d, 'skipped', why);
}
}
}
}
- if (!m || !refAllowed(m.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });
+ if (!m || !refAllowed(m.webhook_url_ref)) return await record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });
const raw = env[m.webhook_url_ref];
const raw = env[m.webhook_url_ref];
- if (!raw) return record(d, { ok: false, retry: true, error: `webhook URL variable ${m.webhook_url_ref} is not set` });
+ if (!raw) return await record(d, { ok: false, retry: true, error: `webhook URL variable ${m.webhook_url_ref} is not set` });
let url = null;
let url = null;
try { url = new URL(raw); } catch { /* below */ }
try { url = new URL(raw); } catch { /* below */ }
- if (!url || !/^https?:$/.test(url.protocol)) return record(d, { ok: false, error: `${m.webhook_url_ref} is not an http(s) URL` });
+ if (!url || !/^https?:$/.test(url.protocol)) return await record(d, { ok: false, error: `${m.webhook_url_ref} is not an http(s) URL` });
let method = 'POST';
let method = 'POST';
let body = null;
let body = null;
@@ -308,13 +317,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -308,13 +317,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
threadTarget = (SNOWFLAKE.test(m.discord_thread_id || '') ? m.discord_thread_id : null) || url.searchParams.get('thread_id') || null;
threadTarget = (SNOWFLAKE.test(m.discord_thread_id || '') ? m.discord_thread_id : null) || url.searchParams.get('thread_id') || null;
url.searchParams.set('wait', 'true');
url.searchParams.set('wait', 'true');
if (threadTarget) url.searchParams.set('thread_id', threadTarget);
if (threadTarget) url.searchParams.set('thread_id', threadTarget);
- body = type === 'thread' ? message(thread, space) : postMessage(post, thread, space);
+ body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);
} else {
} else {
method = d.action === 'edit' ? 'PATCH' : 'DELETE';
method = d.action === 'edit' ? 'PATCH' : 'DELETE';
url.pathname = `${url.pathname.replace(/\/+$/, '')}/messages/${mapped.external_message_id}`;
url.pathname = `${url.pathname.replace(/\/+$/, '')}/messages/${mapped.external_message_id}`;
url.search = '';
url.search = '';
if (mapped.external_thread_id) url.searchParams.set('thread_id', mapped.external_thread_id);
if (mapped.external_thread_id) url.searchParams.set('thread_id', mapped.external_thread_id);
- if (d.action === 'edit') body = type === 'thread' ? message(thread, space) : postMessage(post, thread, space);
+ if (d.action === 'edit') body = type === 'thread' ? await message(thread, space) : await postMessage(post, thread, space);
}
}
let res;
let res;
@@ -326,41 +335,41 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -326,41 +335,41 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
});
} catch (err) {
} catch (err) {
- return record(d, { ok: false, retry: true, error: err.name === 'TimeoutError' ? 'timed out' : err.message });
+ return await record(d, { ok: false, retry: true, error: err.name === 'TimeoutError' ? 'timed out' : err.message });
}
}
let answer = null;
let answer = null;
if (res.status !== 204) { try { answer = await res.json(); } catch { /* no JSON */ } }
if (res.status !== 204) { try { answer = await res.json(); } catch { /* no JSON */ } }
if (res.ok) {
if (res.ok) {
- if (d.action === 'create') return created(d, res.status, answer, { type, localId, threadTarget, mapping: m });
- if (d.action === 'delete') db.prepare('UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(mapped.id);
- else db.prepare('UPDATE relay_message_map SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(mapped.id);
- return record(d, { ok: true, status: res.status });
+ if (d.action === 'create') return await created(d, res.status, answer, { type, localId, threadTarget, mapping: m });
+ if (d.action === 'delete') await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);
+ else await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(mapped.id);
+ return await record(d, { ok: true, status: res.status });
}
}
// The message itself is gone on Discord (someone deleted it there): a delete is done, an edit has nothing to edit.
// The message itself is gone on Discord (someone deleted it there): a delete is done, an edit has nothing to edit.
if (res.status === 404 && d.action !== 'create' && answer && Number(answer.code) === UNKNOWN_MESSAGE) {
if (res.status === 404 && d.action !== 'create' && answer && Number(answer.code) === UNKNOWN_MESSAGE) {
- db.prepare('UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(mapped.id);
- return d.action === 'delete' ? record(d, { ok: true, status: 404, note: 'the message was already gone on Discord' }) : settle(d, 'skipped', 'the message is gone on Discord');
+ await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ?').run(mapped.id);
+ return d.action === 'delete' ? await record(d, { ok: true, status: 404, note: 'the message was already gone on Discord' }) : await settle(d, 'skipped', 'the message is gone on Discord');
}
}
const detail = answer && (answer.message || answer.error) ? String(answer.message || answer.error) : '';
const detail = answer && (answer.message || answer.error) ? String(answer.message || answer.error) : '';
let retryAfter = Number(res.headers.get('retry-after')) || 0;
let retryAfter = Number(res.headers.get('retry-after')) || 0;
if (answer && answer.retry_after) retryAfter = Math.max(retryAfter, Number(answer.retry_after) || 0);
if (answer && answer.retry_after) retryAfter = Math.max(retryAfter, Number(answer.retry_after) || 0);
const retry = res.status === 429 || res.status === 408 || res.status >= 500;
const retry = res.status === 429 || res.status === 408 || res.status >= 500;
- const outcome = record(d, { ok: false, retry, retryAfter, status: res.status, error: `Discord answered ${res.status}${detail ? `: ${detail}` : ''}` });
+ const outcome = await record(d, { ok: false, retry, retryAfter, status: res.status, error: `Discord answered ${res.status}${detail ? `: ${detail}` : ''}` });
return res.status === 429 ? { outcome, rateLimited: true } : outcome;
return res.status === 429 ? { outcome, rateLimited: true } : outcome;
}
}
/** A create was answered: map the message (both ways unique) and learn the mapping's channel. */
/** A create was answered: map the message (both ways unique) and learn the mapping's channel. */
- function created(d, status, msg, { type, localId, threadTarget, mapping }) {
+ async function created(d, status, msg, { type, localId, threadTarget, mapping }) {
const id = msg && SNOWFLAKE.test(String(msg.id || '')) ? String(msg.id) : null;
const id = msg && SNOWFLAKE.test(String(msg.id || '')) ? String(msg.id) : null;
const channel = msg && SNOWFLAKE.test(String(msg.channel_id || '')) ? String(msg.channel_id) : null;
const channel = msg && SNOWFLAKE.test(String(msg.channel_id || '')) ? String(msg.channel_id) : null;
- if (!id || !channel) return record(d, { ok: true, status, note: 'Discord did not answer with the message, so edits and deletes cannot follow it' });
- return db.transaction(() => {
- db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_thread_id, external_message_id, external_webhook_id)
+ if (!id || !channel) return await record(d, { ok: true, status, note: 'Discord did not answer with the message, so edits and deletes cannot follow it' });
+ return await db.tx(async () => {
+ await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_thread_id, external_message_id, external_webhook_id)
VALUES ('discord', ?, 'out', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`)
VALUES ('discord', ?, 'out', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`)
.run(mapping.id, type, localId, d.thread_id, channel, threadTarget ? channel : null, id, msg.webhook_id ? String(msg.webhook_id) : null);
.run(mapping.id, type, localId, d.thread_id, channel, threadTarget ? channel : null, id, msg.webhook_id ? String(msg.webhook_id) : null);
- if (!threadTarget) db.prepare('UPDATE relay_mappings SET discord_channel_id = ? WHERE id = ? AND discord_channel_id IS NULL').run(channel, mapping.id);
- return record(d, { ok: true, status });
- })();
+ if (!threadTarget) await db.prepare('UPDATE relay_mappings SET discord_channel_id = ? WHERE id = ? AND discord_channel_id IS NULL').run(channel, mapping.id);
+ return await record(d, { ok: true, status });
+ });
}
}
/**
/**
@@ -374,7 +383,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -374,7 +383,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const summary = { delivered: 0, retry: 0, failed: 0, skipped: 0 };
const summary = { delivered: 0, retry: 0, failed: 0, skipped: 0 };
if (!enabled) return summary;
if (!enabled) return summary;
// Deletes go out even for a disabled mapping: what left the site leaves Discord too.
// Deletes go out even for a disabled mapping: what left the site leaves Discord too.
- const due = db.prepare(`SELECT d.* FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id
+ const due = await db.prepare(`SELECT d.* FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id
WHERE d.status = 'pending' AND (m.enabled = 1 OR d.action = 'delete') AND d.next_attempt_at <= ?
WHERE d.status = 'pending' AND (m.enabled = 1 OR d.action = 'delete') AND d.next_attempt_at <= ?
ORDER BY d.id ASC LIMIT 50`).all(sqlTime(now()));
ORDER BY d.id ASC LIMIT 50`).all(sqlTime(now()));
const paused = new Set();
const paused = new Set();
@@ -409,6 +418,8 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -409,6 +418,8 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
stopped = true;
stopped = true;
if (timer) clearInterval(timer);
if (timer) clearInterval(timer);
timer = null;
timer = null;
+ if (kickTimer) clearTimeout(kickTimer);
+ kickTimer = null;
const waits = [draining || Promise.resolve()];
const waits = [draining || Promise.resolve()];
if (parts.worker) waits.push(parts.worker.stop());
if (parts.worker) waits.push(parts.worker.stop());
if (parts.gateway) waits.push(parts.gateway.stop());
if (parts.gateway) waits.push(parts.gateway.stop());
@@ -423,7 +434,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -423,7 +434,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
};
};
}
}
const mappingQuery = 'SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id';
const mappingQuery = 'SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id';
- function listMappings() { return db.prepare(`${mappingQuery} ORDER BY m.id`).all().map(shapeMapping); }
+ async function listMappings() { return (await db.prepare(`${mappingQuery} ORDER BY m.id`).all()).map(shapeMapping); }
const snowflakeOrNull = (v, name) => {
const snowflakeOrNull = (v, name) => {
if (v === undefined) return undefined;
if (v === undefined) return undefined;
if (v === null || v === '') return null;
if (v === null || v === '') return null;
@@ -431,17 +442,17 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -431,17 +442,17 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
return String(v);
return String(v);
};
};
/** { space_id, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? } → mapping (made, or updated) */
/** { space_id, webhook_url_ref, enabled?, discord_channel_id?, discord_thread_id?, inbound? } → mapping (made, or updated) */
- function addMapping({ space_id, webhook_url_ref, enabled: on = true, discord_channel_id, discord_thread_id, inbound }) {
+ async function addMapping({ space_id, webhook_url_ref, enabled: on = true, discord_channel_id, discord_thread_id, inbound }) {
if (!refAllowed(webhook_url_ref)) throw new Error(`${webhook_url_ref} is not an allowed webhook variable`);
if (!refAllowed(webhook_url_ref)) throw new Error(`${webhook_url_ref} is not an allowed webhook variable`);
const channel = snowflakeOrNull(discord_channel_id, 'discord_channel_id');
const channel = snowflakeOrNull(discord_channel_id, 'discord_channel_id');
const thread = snowflakeOrNull(discord_thread_id, 'discord_thread_id');
const thread = snowflakeOrNull(discord_thread_id, 'discord_thread_id');
- db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled, discord_channel_id, discord_thread_id, inbound) VALUES (?, 'out', ?, ?, ?, ?, ?)
+ await db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled, discord_channel_id, discord_thread_id, inbound) VALUES (?, 'out', ?, ?, ?, ?, ?)
ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0, channel || null, thread || null, inbound ? 1 : 0);
ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0, channel || null, thread || null, inbound ? 1 : 0);
- const row = db.prepare(`${mappingQuery} WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?`).get(space_id, webhook_url_ref);
- return updateMapping(row.id, { discord_channel_id: channel, discord_thread_id: thread, inbound });
+ const row = await db.prepare(`${mappingQuery} WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?`).get(space_id, webhook_url_ref);
+ return await updateMapping(row.id, { discord_channel_id: channel, discord_thread_id: thread, inbound });
}
}
/** { enabled?, inbound?, discord_channel_id?, discord_thread_id? } → mapping, or null when there is no such mapping */
/** { enabled?, inbound?, discord_channel_id?, discord_thread_id? } → mapping, or null when there is no such mapping */
- function updateMapping(id, fields = {}) {
+ async function updateMapping(id, fields = {}) {
const sets = [];
const sets = [];
const params = [];
const params = [];
if (fields.enabled !== undefined) { sets.push('enabled = ?'); params.push(fields.enabled ? 1 : 0); }
if (fields.enabled !== undefined) { sets.push('enabled = ?'); params.push(fields.enabled ? 1 : 0); }
@@ -450,16 +461,16 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -450,16 +461,16 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const v = snowflakeOrNull(fields[k], k);
const v = snowflakeOrNull(fields[k], k);
if (v !== undefined) { sets.push(`${k} = ?`); params.push(v); }
if (v !== undefined) { sets.push(`${k} = ?`); params.push(v); }
}
}
- if (sets.length && !db.prepare(`UPDATE relay_mappings SET ${sets.join(', ')} WHERE id = ?`).run(...params, id).changes) return null;
- const row = db.prepare(`${mappingQuery} WHERE m.id = ?`).get(id);
+ if (sets.length && !(await db.prepare(`UPDATE relay_mappings SET ${sets.join(', ')} WHERE id = ?`).run(...params, id)).changes) return null;
+ const row = await db.prepare(`${mappingQuery} WHERE m.id = ?`).get(id);
return row ? shapeMapping(row) : null;
return row ? shapeMapping(row) : null;
}
}
- function setMappingEnabled(id, on) { return updateMapping(id, { enabled: on }); }
+ async function setMappingEnabled(id, on) { return await updateMapping(id, { enabled: on }); }
- function listDeliveries({ status = null, action = null, limit = 50 } = {}) {
- return db.prepare(`SELECT d.*, m.webhook_url_ref, t.title AS thread_title, t.slug AS thread_slug, s.slug AS space_slug
+ async function listDeliveries({ status = null, action = null, limit = 50 } = {}) {
+ return (await db.prepare(`SELECT d.*, m.webhook_url_ref, t.title AS thread_title, t.slug AS thread_slug, s.slug AS space_slug
FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id JOIN threads t ON t.id = d.thread_id JOIN spaces s ON s.id = t.space_id
FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id JOIN threads t ON t.id = d.thread_id JOIN spaces s ON s.id = t.space_id
- WHERE (? IS NULL OR d.status = ?) AND (? IS NULL OR d.action = ?) ORDER BY d.id DESC LIMIT ?`).all(status, status, action, action, limit)
+ WHERE (?::text IS NULL OR d.status = ?) AND (?::text IS NULL OR d.action = ?) ORDER BY d.id DESC LIMIT ?`).all(status, status, action, action, limit))
.map((d) => ({
.map((d) => ({
id: d.id, dedupe_key: d.dedupe_key, action: d.action, source: d.source, event_id: d.event_id || null,
id: d.id, dedupe_key: d.dedupe_key, action: d.action, source: d.source, event_id: d.event_id || null,
status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,
status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,
@@ -470,30 +481,30 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -470,30 +481,30 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
}));
}));
}
}
/** Put a delivery that was not delivered back in the queue now, with a fresh attempt budget. */
/** Put a delivery that was not delivered back in the queue now, with a fresh attempt budget. */
- function retry(id) {
- const n = db.prepare("UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status <> 'delivered'").run(sqlTime(now()), id).changes;
+ async function retry(id) {
+ const n = (await db.prepare("UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = ov_now() WHERE id = ? AND status <> 'delivered'").run(sqlTime(now()), id)).changes;
if (n) kick();
if (n) kick();
return n;
return n;
}
}
/** Staff give up on a waiting or dead delivery: 'dropped', kept for the record. */
/** Staff give up on a waiting or dead delivery: 'dropped', kept for the record. */
- function drop(id) {
- return db.prepare("UPDATE relay_deliveries SET status = 'dropped', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('pending', 'failed')").run(id).changes;
+ async function drop(id) {
+ return (await db.prepare("UPDATE relay_deliveries SET status = 'dropped', updated_at = ov_now() WHERE id = ? AND status IN ('pending', 'failed')").run(id)).changes;
}
}
/** What staff (and /api/ready) see: the queue by status, and the Events worker's and the gateway's state. */
/** What staff (and /api/ready) see: the queue by status, and the Events worker's and the gateway's state. */
- function status() {
+ async function status() {
const deliveries = Object.fromEntries(DELIVERY_STATUSES.map((s) => [s, 0]));
const deliveries = Object.fromEntries(DELIVERY_STATUSES.map((s) => [s, 0]));
- for (const r of db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) deliveries[r.status] = r.n;
+ for (const r of await db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) deliveries[r.status] = r.n;
const off = (reason) => ({ enabled: false, reason });
const off = (reason) => ({ enabled: false, reason });
return {
return {
enabled,
enabled,
creates_from: createsFromEvents ? 'events' : 'forum',
creates_from: createsFromEvents ? 'events' : 'forum',
deliveries,
deliveries,
dead_letters: deliveries.failed,
dead_letters: deliveries.failed,
- messages_mapped: db.prepare('SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL').get().n,
- events_worker: parts.worker ? parts.worker.status() : off(enabled ? 'not configured' : 'the relay is off'),
+ messages_mapped: (await db.prepare('SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL').get()).n,
+ events_worker: parts.worker ? await parts.worker.status() : off(enabled ? 'not configured' : 'the relay is off'),
inbound: parts.gateway ? { ...parts.gateway.status(), ...(parts.inbound ? { handled: parts.inbound.stats() } : {}) } : off(enabled ? 'DISCORD_RELAY_INBOUND is off' : 'the relay is off'),
inbound: parts.gateway ? { ...parts.gateway.status(), ...(parts.inbound ? { handled: parts.inbound.stats() } : {}) } : off(enabled ? 'DISCORD_RELAY_INBOUND is off' : 'the relay is off'),
- inbound_failures: db.prepare('SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL').get().n,
+ inbound_failures: (await db.prepare('SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL').get()).n,
};
};
}
}
@@ -51,28 +51,28 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
@@ -51,28 +51,28 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
let stopped = false;
let stopped = false;
const iso = () => new Date(now()).toISOString();
const iso = () => new Date(now()).toISOString();
- const readCursor = () => db.prepare('SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?').get(CURSOR) || null;
- function writeCursor(cursor, latest) {
- db.prepare(`INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = CURRENT_TIMESTAMP`).run(CURSOR, cursor, latest == null ? null : latest);
+ const readCursor = async () => await db.prepare('SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?').get(CURSOR) || null;
+ async function writeCursor(cursor, latest) {
+ await db.prepare(`INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, ov_now())
+ ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = ov_now()`).run(CURSOR, cursor, latest == null ? null : latest);
}
}
/** One event → deliveries queued (0 when it is not for the relay). */
/** One event → deliveries queued (0 when it is not for the relay). */
- function handle(ev) {
+ async function handle(ev) {
stats.seen++;
stats.seen++;
if (!ev || ev.source !== 'community' || !ev.payload || ev.payload.redacted) return 0;
if (!ev || ev.source !== 'community' || !ev.payload || ev.payload.redacted) return 0;
const p = ev.payload;
const p = ev.payload;
if (p.visibility && p.visibility !== 'public') return 0;
if (p.visibility && p.visibility !== 'public') return 0;
if (ev.event_type === 'community.thread.created') {
if (ev.event_type === 'community.thread.created') {
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(Number(p.thread_id));
- const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
- return relay.enqueueThread(thread, space, { source: 'events', eventId: ev.event_id || null });
+ const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(Number(p.thread_id));
+ const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;
+ return await relay.enqueueThread(thread, space, { source: 'events', eventId: ev.event_id || null });
}
}
if (ev.event_type === 'community.post.created') {
if (ev.event_type === 'community.post.created') {
- const post = forumStore.getPost(db, Number(p.post_id));
- const thread = post ? db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id) : null;
- const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
- return relay.enqueuePost(post, thread, space, { source: 'events', eventId: ev.event_id || null });
+ const post = await forumStore.getPost(db, Number(p.post_id));
+ const thread = post ? await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id) : null;
+ const space = thread ? await forumStore.getSpaceById(db, thread.space_id) : null;
+ return await relay.enqueuePost(post, thread, space, { source: 'events', eventId: ev.event_id || null });
}
}
return 0;
return 0;
}
}
@@ -84,11 +84,11 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
@@ -84,11 +84,11 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
running = (async () => {
running = (async () => {
let queuedNow = 0;
let queuedNow = 0;
try {
try {
- let cur = readCursor();
+ let cur = await readCursor();
if (!cur) {
if (!cur) {
const head = await client.pull({ topic: TOPICS, afterSeq: 0, limit: 1 });
const head = await client.pull({ topic: TOPICS, afterSeq: 0, limit: 1 });
const latest = Number(head.latest_seq) || 0;
const latest = Number(head.latest_seq) || 0;
- writeCursor(latest, latest);
+ await writeCursor(latest, latest);
stats.started_at_seq = latest;
stats.started_at_seq = latest;
console.log(`[Relay] Events worker starts at seq ${latest} (earlier threads are not announced)`);
console.log(`[Relay] Events worker starts at seq ${latest} (earlier threads are not announced)`);
cur = { cursor: latest, latest_seq: latest };
cur = { cursor: latest, latest_seq: latest };
@@ -104,12 +104,12 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
@@ -104,12 +104,12 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
const next = Number(page.next_after_seq);
const next = Number(page.next_after_seq);
const latest = Number(page.latest_seq);
const latest = Number(page.latest_seq);
const moved = Number.isFinite(next) && next > cursor ? next : cursor;
const moved = Number.isFinite(next) && next > cursor ? next : cursor;
- queuedNow += db.transaction(() => {
+ queuedNow += await db.tx(async () => {
let n = 0;
let n = 0;
- for (const item of page.events || []) n += handle(item && item.event);
- writeCursor(moved, Number.isFinite(latest) ? latest : null);
+ for (const item of page.events || []) n += await handle(item && item.event);
+ await writeCursor(moved, Number.isFinite(latest) ? latest : null);
return n;
return n;
- })();
+ });
stats.pages++;
stats.pages++;
if (moved === cursor || !Number.isFinite(latest) || moved >= latest) break;
if (moved === cursor || !Number.isFinite(latest) || moved >= latest) break;
cursor = moved;
cursor = moved;
@@ -146,9 +146,9 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
@@ -146,9 +146,9 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
return running ? running.then(() => undefined) : Promise.resolve();
return running ? running.then(() => undefined) : Promise.resolve();
}
}
- function status() {
+ async function status() {
if (!on) return { enabled: false, reason };
if (!on) return { enabled: false, reason };
- const cur = readCursor();
+ const cur = await readCursor();
return {
return {
enabled: true, running: !!timer, topics: TOPICS,
enabled: true, running: !!timer, topics: TOPICS,
cursor: cur ? cur.cursor : null, latest_seq: cur ? cur.latest_seq : null,
cursor: cur ? cur.cursor : null, latest_seq: cur ? cur.latest_seq : null,
@@ -74,123 +74,123 @@ function createDiscordInbound({ db, perMinute = 6, maxChars = 4000, now = () =>
@@ -74,123 +74,123 @@ function createDiscordInbound({ db, perMinute = 6, maxChars = 4000, now = () =>
return true;
return true;
}
}
- const mapByMessage = (id) => db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?").get(String(id)) || null;
- const mappingById = (id) => db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(id) || null;
+ const mapByMessage = async (id) => await db.prepare("SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?").get(String(id)) || null;
+ const mappingById = async (id) => await db.prepare('SELECT * FROM relay_mappings WHERE id = ?').get(id) || null;
function ignore(reason) { counts.ignored++; return { status: 'ignored', reason }; }
function ignore(reason) { counts.ignored++; return { status: 'ignored', reason }; }
- function failure(event, d, mappingId, error) {
- db.prepare(`INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error)
+ async function failure(event, d, mappingId, error) {
+ await db.prepare(`INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error)
VALUES (?, ?, ?, ?, ?, ?)`).run(mappingId || null, event, d.channel_id ? String(d.channel_id) : null, d.id ? String(d.id) : null, d.author && d.author.id ? String(d.author.id) : null, String(error).slice(0, 300));
VALUES (?, ?, ?, ?, ?, ?)`).run(mappingId || null, event, d.channel_id ? String(d.channel_id) : null, d.id ? String(d.id) : null, d.author && d.author.id ? String(d.author.id) : null, String(error).slice(0, 300));
- db.prepare('DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?').run(KEEP_FAILURES);
+ await db.prepare('DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?').run(KEEP_FAILURES);
counts.failed++;
counts.failed++;
return { status: 'failed', error };
return { status: 'failed', error };
}
}
/** Is the local thread still somewhere a reply from Discord may land? → null or why not */
/** Is the local thread still somewhere a reply from Discord may land? → null or why not */
- function closed(thread) {
+ async function closed(thread) {
if (!thread || thread.deleted_at) return 'the thread was deleted';
if (!thread || thread.deleted_at) return 'the thread was deleted';
- const space = forumStore.getSpaceById(db, thread.space_id);
+ const space = await forumStore.getSpaceById(db, thread.space_id);
if (!space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is not public';
if (!space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return 'the thread is not public';
if (thread.locked) return 'the thread is locked';
if (thread.locked) return 'the thread is locked';
return null;
return null;
}
}
- function create(d, botUserId) {
+ async function create(d, botUserId) {
if (d.webhook_id) return ignore('a webhook message (the relay\'s own included)');
if (d.webhook_id) return ignore('a webhook message (the relay\'s own included)');
const author = d.author || {};
const author = d.author || {};
if (!author.id || author.bot || author.system || (botUserId && String(author.id) === String(botUserId))) return ignore('a bot or system message');
if (!author.id || author.bot || author.system || (botUserId && String(author.id) === String(botUserId))) return ignore('a bot or system message');
if (d.type !== undefined && !MESSAGE_TYPES.has(Number(d.type))) return ignore('not a plain message or a reply');
if (d.type !== undefined && !MESSAGE_TYPES.has(Number(d.type))) return ignore('not a plain message or a reply');
if (!SNOWFLAKE.test(String(d.id || '')) || !SNOWFLAKE.test(String(d.channel_id || ''))) return ignore('malformed ids');
if (!SNOWFLAKE.test(String(d.id || '')) || !SNOWFLAKE.test(String(d.channel_id || ''))) return ignore('malformed ids');
- if (mapByMessage(d.id)) return ignore('already relayed');
+ if (await mapByMessage(d.id)) return ignore('already relayed');
const channel = String(d.channel_id);
const channel = String(d.channel_id);
// A Discord thread started from a mapped message, else a reply to one in the same channel.
// A Discord thread started from a mapped message, else a reply to one in the same channel.
- let anchor = mapByMessage(channel);
+ let anchor = await mapByMessage(channel);
// (message_reference type 1 is a forward, not a reply: never taken in.)
// (message_reference type 1 is a forward, not a reply: never taken in.)
if (!anchor && d.message_reference && d.message_reference.message_id && !Number(d.message_reference.type || 0)) {
if (!anchor && d.message_reference && d.message_reference.message_id && !Number(d.message_reference.type || 0)) {
- const ref = mapByMessage(String(d.message_reference.message_id));
+ const ref = await mapByMessage(String(d.message_reference.message_id));
if (ref && ref.external_channel_id === channel) anchor = ref;
if (ref && ref.external_channel_id === channel) anchor = ref;
}
}
if (!anchor) return ignore('not a reply to a relayed message');
if (!anchor) return ignore('not a reply to a relayed message');
- const mapping = mappingById(anchor.mapping_id);
+ const mapping = await mappingById(anchor.mapping_id);
if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');
if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');
const mapped = [mapping.discord_channel_id, mapping.discord_thread_id].filter(Boolean);
const mapped = [mapping.discord_channel_id, mapping.discord_thread_id].filter(Boolean);
const inMapped = mapped.includes(channel) || (channel === anchor.external_message_id && mapped.includes(anchor.external_channel_id));
const inMapped = mapped.includes(channel) || (channel === anchor.external_message_id && mapped.includes(anchor.external_channel_id));
if (!inMapped) return ignore('not a mapped channel');
if (!inMapped) return ignore('not a mapped channel');
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(anchor.thread_id);
- const why = closed(thread);
- if (why) return failure('MESSAGE_CREATE', d, mapping.id, why);
- if (!allow(`a:${mapping.id}:${author.id}`, perMinute) || !allow(`m:${mapping.id}`, perMinute * 10)) return failure('MESSAGE_CREATE', d, mapping.id, 'rate limited');
+ const thread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(anchor.thread_id);
+ const why = await closed(thread);
+ if (why) return await failure('MESSAGE_CREATE', d, mapping.id, why);
+ if (!allow(`a:${mapping.id}:${author.id}`, perMinute) || !allow(`m:${mapping.id}`, perMinute * 10)) return await failure('MESSAGE_CREATE', d, mapping.id, 'rate limited');
const body = toMarkdown(d, maxChars);
const body = toMarkdown(d, maxChars);
- if (!body) return failure('MESSAGE_CREATE', d, mapping.id, 'the message has no text: is the MESSAGE CONTENT intent on for the bot?');
+ if (!body) return await failure('MESSAGE_CREATE', d, mapping.id, 'the message has no text: is the MESSAGE CONTENT intent on for the bot?');
const name = cleanName((d.member && d.member.nick) || author.global_name || author.username) || 'Discord';
const name = cleanName((d.member && d.member.nick) || author.global_name || author.username) || 'Discord';
try {
try {
- const post = db.transaction(() => {
- const p = forumStore.addPost(db, { thread_id: thread.id, author_subject: null, origin: 'discord', body_markdown: body, relay_author: name });
- db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)
+ const post = await db.tx(async () => {
+ const p = await forumStore.addPost(db, { thread_id: thread.id, author_subject: null, origin: 'discord', body_markdown: body, relay_author: name });
+ await db.prepare(`INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)
VALUES ('discord', ?, 'in', 'post', ?, ?, ?, ?)`).run(mapping.id, p.id, thread.id, channel, String(d.id));
VALUES ('discord', ?, 'in', 'post', ?, ?, ?, ?)`).run(mapping.id, p.id, thread.id, channel, String(d.id));
return p;
return p;
- })();
+ });
counts.applied++;
counts.applied++;
return { status: 'applied', post_id: post.id };
return { status: 'applied', post_id: post.id };
} catch (err) {
} catch (err) {
- if (/UNIQUE/.test(err.message)) return ignore('already relayed');
- return failure('MESSAGE_CREATE', d, mapping.id, `could not save the post: ${err.message}`);
+ if (err.code === '23505') return ignore('already relayed');
+ return await failure('MESSAGE_CREATE', d, mapping.id, `could not save the post: ${err.message}`);
}
}
}
}
- function update(d) {
+ async function update(d) {
if (!d.id) return ignore('malformed ids');
if (!d.id) return ignore('malformed ids');
- const row = mapByMessage(d.id);
+ const row = await mapByMessage(d.id);
if (!row) return ignore('not a relayed message');
if (!row) return ignore('not a relayed message');
if (row.direction !== 'in') return ignore('the relay\'s own message');
if (row.direction !== 'in') return ignore('the relay\'s own message');
if (typeof d.content !== 'string') return ignore('no text change');
if (typeof d.content !== 'string') return ignore('no text change');
- const mapping = mappingById(row.mapping_id);
+ const mapping = await mappingById(row.mapping_id);
if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');
if (!mapping || !mapping.enabled || !mapping.inbound) return ignore('inbound is off for this mapping');
- const post = forumStore.getPost(db, row.local_id);
- if (!post || post.deleted_at) return failure('MESSAGE_UPDATE', d, mapping.id, 'the post was deleted');
- const why = closed(db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id));
- if (why) return failure('MESSAGE_UPDATE', d, mapping.id, why);
+ const post = await forumStore.getPost(db, row.local_id);
+ if (!post || post.deleted_at) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the post was deleted');
+ const why = await closed(await db.prepare('SELECT * FROM threads WHERE id = ?').get(post.thread_id));
+ if (why) return await failure('MESSAGE_UPDATE', d, mapping.id, why);
const body = toMarkdown(d, maxChars);
const body = toMarkdown(d, maxChars);
- if (!body) return failure('MESSAGE_UPDATE', d, mapping.id, 'the edit has no text');
- forumStore.editPost(db, post.id, body, 'discord');
- db.prepare('UPDATE relay_message_map SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
+ if (!body) return await failure('MESSAGE_UPDATE', d, mapping.id, 'the edit has no text');
+ await forumStore.editPost(db, post.id, body, 'discord');
+ await db.prepare('UPDATE relay_message_map SET updated_at = ov_now() WHERE id = ?').run(row.id);
counts.applied++;
counts.applied++;
return { status: 'applied', post_id: post.id };
return { status: 'applied', post_id: post.id };
}
}
/** A delete on Discord. Applied whatever the mapping's switches: taking something down never waits. */
/** A delete on Discord. Applied whatever the mapping's switches: taking something down never waits. */
- function remove(id) {
- const row = mapByMessage(id);
+ async function remove(id) {
+ const row = await mapByMessage(id);
if (!row) return ignore('not a relayed message');
if (!row) return ignore('not a relayed message');
- db.transaction(() => {
- db.prepare('UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND external_deleted_at IS NULL').run(row.id);
- if (row.direction === 'in') forumStore.softDeletePost(db, row.local_id);
- })();
+ await db.tx(async () => {
+ await db.prepare('UPDATE relay_message_map SET external_deleted_at = ov_now(), updated_at = ov_now() WHERE id = ? AND external_deleted_at IS NULL').run(row.id);
+ if (row.direction === 'in') await forumStore.softDeletePost(db, row.local_id);
+ });
counts.applied++;
counts.applied++;
return { status: 'applied', post_id: row.direction === 'in' ? row.local_id : null, note: row.direction === 'out' ? 'the relay\'s message was deleted on Discord; the thread stays' : undefined };
return { status: 'applied', post_id: row.direction === 'in' ? row.local_id : null, note: row.direction === 'out' ? 'the relay\'s message was deleted on Discord; the thread stays' : undefined };
}
}
/** One gateway dispatch (type, data, { botUserId }) → result (an array for a bulk delete). */
/** One gateway dispatch (type, data, { botUserId }) → result (an array for a bulk delete). */
- function handle(type, d, { botUserId = null } = {}) {
+ async function handle(type, d, { botUserId = null } = {}) {
if (!d || typeof d !== 'object') return ignore('no data');
if (!d || typeof d !== 'object') return ignore('no data');
- if (type === 'MESSAGE_CREATE') return create(d, botUserId);
- if (type === 'MESSAGE_UPDATE') return update(d);
- if (type === 'MESSAGE_DELETE') return remove(String(d.id || ''));
- if (type === 'MESSAGE_DELETE_BULK') return (Array.isArray(d.ids) ? d.ids : []).map((id) => remove(String(id)));
+ if (type === 'MESSAGE_CREATE') return await create(d, botUserId);
+ if (type === 'MESSAGE_UPDATE') return await update(d);
+ if (type === 'MESSAGE_DELETE') return await remove(String(d.id || ''));
+ if (type === 'MESSAGE_DELETE_BULK') return (await Promise.all((Array.isArray(d.ids) ? d.ids : []).map(async (id) => await remove(String(id)))));
return ignore('not a message event');
return ignore('not a message event');
}
}
// ── staff ────────────────────────────────────────────────
// ── staff ────────────────────────────────────────────────
- function listFailures({ all = false, limit = 50 } = {}) {
- return db.prepare(`SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id
- WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?`).all(all ? 1 : 0, limit)
+ async function listFailures({ all = false, limit = 50 } = {}) {
+ return (await db.prepare(`SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id
+ WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?`).all(all ? 1 : 0, limit))
.map((f) => ({
.map((f) => ({
id: f.id, event: f.event, error: f.error, mapping: f.mapping_id ? { id: f.mapping_id, space: f.space_slug || null } : null,
id: f.id, event: f.event, error: f.error, mapping: f.mapping_id ? { id: f.mapping_id, space: f.space_slug || null } : null,
discord: { channel_id: f.external_channel_id, message_id: f.external_message_id, author_id: f.external_author_id },
discord: { channel_id: f.external_channel_id, message_id: f.external_message_id, author_id: f.external_author_id },
dismissed: !!f.dismissed_at, created_at: f.created_at ? new Date(`${String(f.created_at).replace(' ', 'T')}Z`).toISOString() : null,
dismissed: !!f.dismissed_at, created_at: f.created_at ? new Date(`${String(f.created_at).replace(' ', 'T')}Z`).toISOString() : null,
}));
}));
}
}
- function dismiss(id) {
- return db.prepare('UPDATE relay_inbound_failures SET dismissed_at = CURRENT_TIMESTAMP WHERE id = ? AND dismissed_at IS NULL').run(id).changes;
+ async function dismiss(id) {
+ return (await db.prepare('UPDATE relay_inbound_failures SET dismissed_at = ov_now() WHERE id = ? AND dismissed_at IS NULL').run(id)).changes;
}
}
return { handle, listFailures, dismiss, stats: () => ({ ...counts }) };
return { handle, listFailures, dismiss, stats: () => ({ ...counts }) };
@@ -48,25 +48,17 @@ function createSearchDocuments({ db }) {
@@ -48,25 +48,17 @@ function createSearchDocuments({ db }) {
let lastScan = null;
let lastScan = null;
let timers = [];
let timers = [];
- db.exec(`CREATE TABLE IF NOT EXISTS search_doc_pushes (
- type TEXT NOT NULL,
- id TEXT NOT NULL,
- hash TEXT NOT NULL,
- revision INTEGER NOT NULL,
- deleted INTEGER NOT NULL DEFAULT 0,
- pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (type, id)
- )`);
+ // search_doc_pushes is in migrations/0001_initial.sql.
const enabled = () => !!events.status().enabled;
const enabled = () => !!events.status().enabled;
/** The document for one thread, `{ deleted: true }` when Search must not hold it. */
/** The document for one thread, `{ deleted: true }` when Search must not hold it. */
- function threadDocument(threadId) {
- const t = db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name, s.visibility AS space_visibility,
+ async function threadDocument(threadId) {
+ const t = await db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name, s.visibility AS space_visibility,
s.members_only_owner AS space_members_only, c.slug AS category_slug
s.members_only_owner AS space_members_only, c.slug AS category_slug
FROM threads t JOIN spaces s ON s.id = t.space_id LEFT JOIN categories c ON c.id = t.category_id
FROM threads t JOIN spaces s ON s.id = t.space_id LEFT JOIN categories c ON c.id = t.category_id
WHERE t.id = ?`).get(threadId);
WHERE t.id = ?`).get(threadId);
if (!t || t.deleted_at || t.space_visibility !== 'public' || t.space_members_only || t.members_only_owner) return { deleted: true };
if (!t || t.deleted_at || t.space_visibility !== 'public' || t.space_members_only || t.members_only_owner) return { deleted: true };
- const posts = db.prepare('SELECT body_markdown, is_opening FROM posts WHERE thread_id = ? AND deleted_at IS NULL ORDER BY is_opening DESC, id ASC').all(t.id);
+ const posts = await db.prepare('SELECT body_markdown, is_opening FROM posts WHERE thread_id = ? AND deleted_at IS NULL ORDER BY is_opening DESC, id ASC').all(t.id);
const opening = posts.find((p) => p.is_opening);
const opening = posts.find((p) => p.is_opening);
const facets = { space: t.space_slug, kind: t.kind || 'discussion', replies: Number(t.reply_count) || 0, score: Number(t.score) || 0 };
const facets = { space: t.space_slug, kind: t.kind || 'discussion', replies: Number(t.reply_count) || 0, score: Number(t.score) || 0 };
if (t.status) facets.status = String(t.status).slice(0, 200);
if (t.status) facets.status = String(t.status).slice(0, 200);
@@ -111,55 +103,55 @@ function createSearchDocuments({ db }) {
@@ -111,55 +103,55 @@ function createSearchDocuments({ db }) {
}
}
/** Send one document or tombstone when it changed. → 'sent' | 'tombstone' | 'unchanged' | 'skipped' */
/** Send one document or tombstone when it changed. → 'sent' | 'tombstone' | 'unchanged' | 'skipped' */
- function send(type, id, doc) {
+ async function send(type, id, doc) {
if (!enabled()) return 'skipped';
if (!enabled()) return 'skipped';
- const prev = db.prepare('SELECT hash, revision, deleted FROM search_doc_pushes WHERE type = ? AND id = ?').get(type, id);
+ const prev = await db.prepare('SELECT hash, revision, deleted FROM search_doc_pushes WHERE type = ? AND id = ?').get(type, id);
if (doc.deleted && (!prev || prev.deleted)) { stats.unchanged++; return 'unchanged'; } // never sent, or already gone
if (doc.deleted && (!prev || prev.deleted)) { stats.unchanged++; return 'unchanged'; } // never sent, or already gone
const hash = doc.deleted ? 'deleted' : hashOf(doc);
const hash = doc.deleted ? 'deleted' : hashOf(doc);
if (prev && prev.hash === hash) { stats.unchanged++; return 'unchanged'; }
if (prev && prev.hash === hash) { stats.unchanged++; return 'unchanged'; }
const revision = (prev ? prev.revision : 0) + 1;
const revision = (prev ? prev.revision : 0) + 1;
- db.transaction(() => {
- if (doc.deleted) events.record('community.index_document.deleted', { type, id, revision }, { type, id, revision });
- else events.record('community.index_document.upserted', { type, id, revision }, { ...doc, revision });
- db.prepare(`INSERT INTO search_doc_pushes (type, id, hash, revision, deleted, pushed_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ await db.tx(async () => {
+ if (doc.deleted) await events.record('community.index_document.deleted', { type, id, revision }, { type, id, revision });
+ else await events.record('community.index_document.upserted', { type, id, revision }, { ...doc, revision });
+ await db.prepare(`INSERT INTO search_doc_pushes (type, id, hash, revision, deleted, pushed_at) VALUES (?, ?, ?, ?, ?, ov_now())
ON CONFLICT(type, id) DO UPDATE SET hash = excluded.hash, revision = excluded.revision, deleted = excluded.deleted, pushed_at = excluded.pushed_at`)
ON CONFLICT(type, id) DO UPDATE SET hash = excluded.hash, revision = excluded.revision, deleted = excluded.deleted, pushed_at = excluded.pushed_at`)
.run(type, id, hash, revision, doc.deleted ? 1 : 0);
.run(type, id, hash, revision, doc.deleted ? 1 : 0);
- })();
+ });
if (doc.deleted) { stats.tombstones++; return 'tombstone'; }
if (doc.deleted) { stats.tombstones++; return 'tombstone'; }
stats.sent++;
stats.sent++;
return 'sent';
return 'sent';
}
}
- const publishThread = (threadId) => send('thread', String(threadId), threadDocument(threadId));
- function publishPaste(row) {
+ const publishThread = async (threadId) => await send('thread', String(threadId), await threadDocument(threadId));
+ async function publishPaste(row) {
if (!row || !SLUG_RE.test(String(row.slug || ''))) return 'skipped';
if (!row || !SLUG_RE.test(String(row.slug || ''))) return 'skipped';
- return send('paste', pasteDocId(row), pasteDocument(row));
+ return await send('paste', pasteDocId(row), pasteDocument(row));
}
}
- const guard = (fn) => { try { fn(); } catch (err) { stats.lastError = err.message; } };
+ const guard = async (fn) => { try { await fn(); } catch (err) { stats.lastError = err.message; } };
- function scan({ now = Date.now() } = {}) {
+ async function scan({ now = Date.now() } = {}) {
if (!enabled()) return 0;
if (!enabled()) return 0;
const from = lastScan || sqlTime(now - 10 * 60 * 1000);
const from = lastScan || sqlTime(now - 10 * 60 * 1000);
const to = sqlTime(now);
const to = sqlTime(now);
- const threads = db.prepare('SELECT id FROM threads').all();
- for (const t of threads) guard(() => publishThread(t.id));
- const pastes = db.prepare('SELECT * FROM pastes WHERE (updated_at >= @from AND updated_at < @to) OR (deleted_at >= @from AND deleted_at < @to) OR (created_at >= @from AND created_at < @to)').all({ from, to });
- for (const p of pastes) guard(() => publishPaste(p));
+ const threads = await db.prepare('SELECT id FROM threads').all();
+ for (const t of threads) await guard(async () => await publishThread(t.id));
+ const pastes = await db.prepare('SELECT * FROM pastes WHERE (updated_at >= @from AND updated_at < @to) OR (deleted_at >= @from AND deleted_at < @to) OR (created_at >= @from AND created_at < @to)').all({ from, to });
+ for (const p of pastes) await guard(async () => await publishPaste(p));
lastScan = to;
lastScan = to;
stats.lastScanAt = new Date(now).toISOString();
stats.lastScanAt = new Date(now).toISOString();
return threads.length + pastes.length;
return threads.length + pastes.length;
}
}
- function refresh() {
+ async function refresh() {
if (!enabled()) return 0;
if (!enabled()) return 0;
- const rows = db.prepare('SELECT * FROM pastes').all();
+ const rows = await db.prepare('SELECT * FROM pastes').all();
const seen = new Set();
const seen = new Set();
- for (const p of rows) { seen.add(pasteDocId(p)); guard(() => publishPaste(p)); }
- for (const r of db.prepare("SELECT id FROM search_doc_pushes WHERE type = 'paste' AND deleted = 0").all()) {
+ for (const p of rows) { seen.add(pasteDocId(p)); await guard(async () => await publishPaste(p)); }
+ for (const r of await db.prepare("SELECT id FROM search_doc_pushes WHERE type = 'paste' AND deleted = 0").all()) {
if (seen.has(r.id)) continue;
if (seen.has(r.id)) continue;
// An id Search could never accept (sent before paste_<id> existed): nothing to remove there.
// An id Search could never accept (sent before paste_<id> existed): nothing to remove there.
- if (!DOC_ID_RE.test(r.id)) db.prepare("DELETE FROM search_doc_pushes WHERE type = 'paste' AND id = ?").run(r.id);
- else guard(() => send('paste', r.id, { deleted: true }));
+ if (!DOC_ID_RE.test(r.id)) await db.prepare("DELETE FROM search_doc_pushes WHERE type = 'paste' AND id = ?").run(r.id);
+ else await guard(async () => await send('paste', r.id, { deleted: true }));
}
}
return rows.length;
return rows.length;
}
}
@@ -167,12 +159,12 @@ function createSearchDocuments({ db }) {
@@ -167,12 +159,12 @@ function createSearchDocuments({ db }) {
function start() {
function start() {
if (timers.length || !enabled() || process.env.COMMUNITY_SEARCH_DOCUMENTS === 'off') return false;
if (timers.length || !enabled() || process.env.COMMUNITY_SEARCH_DOCUMENTS === 'off') return false;
const every = (ms, fn, first) => {
const every = (ms, fn, first) => {
- const t0 = setTimeout(() => guard(fn), first); if (t0.unref) t0.unref();
- const t = setInterval(() => guard(fn), ms); if (t.unref) t.unref();
+ const t0 = setTimeout(async () => await guard(fn), first); if (t0.unref) t0.unref();
+ const t = setInterval(async () => await guard(fn), ms); if (t.unref) t.unref();
timers.push(t0, t);
timers.push(t0, t);
};
};
- every(SCAN_MS, () => scan(), 45 * 1000);
- every(REFRESH_MS, () => refresh(), 2 * 60 * 1000);
+ every(SCAN_MS, async () => await scan(), 45 * 1000);
+ every(REFRESH_MS, async () => await refresh(), 2 * 60 * 1000);
return true;
return true;
}
}
function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
@@ -176,8 +176,8 @@ async function buildSitemap() {
@@ -176,8 +176,8 @@ async function buildSitemap() {
if (_forum) {
if (_forum) {
add('/s', null, 'hourly', '0.8');
add('/s', null, 'hourly', '0.8');
add('/pulse', null, 'hourly', '0.5');
add('/pulse', null, 'hourly', '0.5');
- for (const s of _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
- for (const t of _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
+ for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
+ for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
}
}
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
}
}
@@ -3,12 +3,11 @@
@@ -3,12 +3,11 @@
/**
/**
* Up/down votes on comments and forum threads — one row per (target, person), value 1 or -1.
* Up/down votes on comments and forum threads — one row per (target, person), value 1 or -1.
*
*
- * Race safety: a vote is a single UPSERT (or DELETE for 0) followed, in the same IMMEDIATE
- * transaction, by recomputing the target's score from the vote rows. The score is never
- * incremented or decremented, so interleaved votes from any number of connections or processes
- * cannot drift it: whatever order the writes land in, the stored score is the sum of the rows
- * that exist when the last transaction commits. IMMEDIATE takes SQLite's write lock up front
- * (instead of upgrading a read lock), so two writers queue on busy_timeout rather than failing.
+ * Race safety: a vote is a single UPSERT (or DELETE for 0) followed, in the same transaction, by
+ * recomputing the target's score from the vote rows. The score is never incremented or decremented,
+ * so interleaved votes from any number of connections or processes cannot drift it. The transaction
+ * first locks the target row (SELECT … FOR UPDATE, PostgreSQL's stand-in for SQLite's IMMEDIATE), so
+ * votes on one target queue and each recount sees every vote committed before it.
*/
*/
const TARGETS = {
const TARGETS = {
comment: { votes: 'comment_votes', key: 'comment_id', table: 'comments', counts: true },
comment: { votes: 'comment_votes', key: 'comment_id', table: 'comments', counts: true },
@@ -16,35 +15,36 @@ const TARGETS = {
@@ -16,35 +15,36 @@ const TARGETS = {
};
};
/** value 1 | -1 | 0 (0 removes the vote). → { score, upvotes, downvotes, my_vote } */
/** value 1 | -1 | 0 (0 removes the vote). → { score, upvotes, downvotes, my_vote } */
-function applyVote(db, kind, targetId, subject, value) {
+async function applyVote(db, kind, targetId, subject, value) {
const t = TARGETS[kind];
const t = TARGETS[kind];
if (!t) throw new Error(`unknown vote target ${kind}`);
if (!t) throw new Error(`unknown vote target ${kind}`);
if (![1, -1, 0].includes(value)) throw new Error('vote value must be 1, -1 or 0');
if (![1, -1, 0].includes(value)) throw new Error('vote value must be 1, -1 or 0');
- return db.transaction(() => {
+ return await db.tx(async () => {
+ await db.prepare(`SELECT id FROM ${t.table} WHERE id = ? FOR UPDATE`).get(targetId);
if (value === 0) {
if (value === 0) {
- db.prepare(`DELETE FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`).run(targetId, subject);
+ await db.prepare(`DELETE FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`).run(targetId, subject);
} else {
} else {
- db.prepare(`INSERT INTO ${t.votes} (${t.key}, subject_id, value) VALUES (?, ?, ?)
- ON CONFLICT(${t.key}, subject_id) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`)
+ await db.prepare(`INSERT INTO ${t.votes} (${t.key}, subject_id, value) VALUES (?, ?, ?)
+ ON CONFLICT(${t.key}, subject_id) DO UPDATE SET value = excluded.value, updated_at = ov_now()`)
.run(targetId, subject, value);
.run(targetId, subject, value);
}
}
- const agg = db.prepare(`SELECT COALESCE(SUM(value), 0) AS score,
- COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
- COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS downvotes
+ const agg = await db.prepare(`SELECT COALESCE(SUM(value), 0)::bigint AS score,
+ COUNT(*) FILTER (WHERE value = 1) AS upvotes,
+ COUNT(*) FILTER (WHERE value = -1) AS downvotes
FROM ${t.votes} WHERE ${t.key} = ?`).get(targetId);
FROM ${t.votes} WHERE ${t.key} = ?`).get(targetId);
- if (t.counts) db.prepare(`UPDATE ${t.table} SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?`).run(agg.score, agg.upvotes, agg.downvotes, targetId);
- else db.prepare(`UPDATE ${t.table} SET score = ? WHERE id = ?`).run(agg.score, targetId);
+ if (t.counts) await db.prepare(`UPDATE ${t.table} SET score = ?, upvotes = ?, downvotes = ? WHERE id = ?`).run(agg.score, agg.upvotes, agg.downvotes, targetId);
+ else await db.prepare(`UPDATE ${t.table} SET score = ? WHERE id = ?`).run(agg.score, targetId);
return { score: agg.score, upvotes: agg.upvotes, downvotes: agg.downvotes, my_vote: value };
return { score: agg.score, upvotes: agg.upvotes, downvotes: agg.downvotes, my_vote: value };
- }).immediate();
+ });
}
}
/** The person's current votes on some targets. → Map id → 1|-1 */
/** The person's current votes on some targets. → Map id → 1|-1 */
-function myVotes(db, kind, targetIds, subject) {
+async function myVotes(db, kind, targetIds, subject) {
const t = TARGETS[kind];
const t = TARGETS[kind];
const out = new Map();
const out = new Map();
if (!subject || !targetIds.length) return out;
if (!subject || !targetIds.length) return out;
- const stmt = db.prepare(`SELECT value FROM ${t.votes} WHERE ${t.key} = ? AND subject_id = ?`);
- for (const id of new Set(targetIds)) { const r = stmt.get(id, subject); if (r) out.set(id, r.value); }
+ const rows = await db.prepare(`SELECT ${t.key} AS id, value FROM ${t.votes} WHERE ${t.key} = ANY(?::bigint[]) AND subject_id = ?`).all([...new Set(targetIds)], subject);
+ for (const r of rows) out.set(r.id, r.value);
return out;
return out;
}
}
@@ -12,36 +12,36 @@ const http = require('http');
@@ -12,36 +12,36 @@ const http = require('http');
const express = require('express');
const express = require('express');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
-const SECRET = `whsec_${'ab'.repeat(32)}`;
-const db = openDb(':memory:');
-const sent = [];
-let failNext = false;
-const accountSend = async (p, body) => { if (failNext) { failNext = false; return { ok: false, status: 503 }; } sent.push({ path: p, body }); return { ok: true, status: 200 }; };
-const app = express();
-app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], accountSend, log: { error() {}, log() {}, warn() {} } }).router);
-const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1', OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2', OTTO = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
-
-db.prepare("INSERT INTO pastes (id, slug, owner_subject, title, content, likes) VALUES (1, 'p1', ?, 'mine, answered', 'secret words', 1), (2, 'p2', ?, 'mine, alone', 'more', 0), (3, 'p3', ?, 'otto''s', 'x', 1), (4, 'p4', ?, 'old account', 'y', 0)").run(DANA, DANA, OTTO, OLD);
-db.prepare("INSERT INTO paste_comments (id, paste_id, author_subject, message, parent_id) VALUES (1, 1, ?, 'nice', NULL), (2, 3, ?, 'hi otto', NULL), (3, 3, ?, 'hi dana', 2), (4, 3, ?, 'bye', NULL)").run(OTTO, DANA, OTTO, DANA);
-db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);
-db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) VALUES (1, 'live', 'vod', '9')").run();
-db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);
-db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);
-db.prepare("INSERT INTO spaces (id, slug, name, created_by) VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);
-db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);
-db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject, is_opening) VALUES (1, 1, 'opening', ?, 1), (2, 1, 'otto answers', ?, 0), (3, 2, 'quiet opening', ?, 1)").run(DANA, OTTO, DANA);
-db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (2, ?, 1)').run(OTTO);
-db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (2, ?, 'fire')").run(DANA);
-db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 4, '2026-09-27')").run(DANA, OTTO);
-require('../server/identity/blocks').ensureSchema(db);
-db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(DANA, OTTO, OTTO, DANA);
-
-const evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
- subject: { type: 'user', id: payload.subject }, actor: { type: 'user', id: payload.subject }, payload });
+let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
(async () => {
(async () => {
+ SECRET = `whsec_${'ab'.repeat(32)}`;
+ db = await testDb();
+ sent = [];
+ failNext = false;
+ accountSend = async (p, body) => { if (failNext) { failNext = false; return { ok: false, status: 503 }; } sent.push({ path: p, body }); return { ok: true, status: 200 }; };
+ app = express();
+ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], accountSend, log: { error() {}, log() {}, warn() {} } }).router);
+ DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
+ OLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';
+ OTTO = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
+ await db.prepare("INSERT INTO pastes (id, slug, owner_subject, title, content, likes) OVERRIDING SYSTEM VALUE VALUES (1, 'p1', ?, 'mine, answered', 'secret words', 1), (2, 'p2', ?, 'mine, alone', 'more', 0), (3, 'p3', ?, 'otto''s', 'x', 1), (4, 'p4', ?, 'old account', 'y', 0)").run(DANA, DANA, OTTO, OLD);
+ await db.prepare("INSERT INTO paste_comments (id, paste_id, author_subject, message, parent_id) OVERRIDING SYSTEM VALUE VALUES (1, 1, ?, 'nice', NULL), (2, 3, ?, 'hi otto', NULL), (3, 3, ?, 'hi dana', 2), (4, 3, ?, 'bye', NULL)").run(OTTO, DANA, OTTO, DANA);
+ await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);
+ await db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'vod', '9')").run();
+ await db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) OVERRIDING SYSTEM VALUE VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);
+ await db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);
+ await db.prepare("INSERT INTO spaces (id, slug, name, created_by) OVERRIDING SYSTEM VALUE VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);
+ await db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) OVERRIDING SYSTEM VALUE VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);
+ await db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject, is_opening) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'opening', ?, 1), (2, 1, 'otto answers', ?, 0), (3, 2, 'quiet opening', ?, 1)").run(DANA, OTTO, DANA);
+ await db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (2, ?, 1)').run(OTTO);
+ await db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (2, ?, 'fire')").run(DANA);
+ await db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 4, '2026-09-27')").run(DANA, OTTO);
+ await db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(DANA, OTTO, OTTO, DANA);
+ evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
+ subject: { type: 'user', id: payload.subject }, actor: { type: 'user', id: payload.subject }, payload });
assert.ok(TOPICS.includes('network.account.export_requested') && TOPICS.includes('network.account.deleted'), 'Community subscribes to both');
assert.ok(TOPICS.includes('network.account.export_requested') && TOPICS.includes('network.account.deleted'), 'Community subscribes to both');
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const post = (event) => {
const post = (event) => {
@@ -70,20 +70,20 @@ const evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1
@@ -70,20 +70,20 @@ const evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1
failNext = true;
failNext = true;
r = await post(del);
r = await post(del);
assert.strictEqual(r.status, 500, 'a failed confirmation is redelivered');
assert.strictEqual(r.status, 500, 'a failed confirmation is redelivered');
- const all = (sql) => db.prepare(sql).all();
- assert.deepStrictEqual(all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [
+ const all = async (sql) => await db.prepare(sql).all();
+ assert.deepStrictEqual(await all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [
{ slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");
{ slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");
- assert.deepStrictEqual(all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [
+ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [
{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
- assert.deepStrictEqual(all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
+ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
{ id: 1, a: null, message: '[deleted]', score: 0, reply_count: 1 }, { id: 2, a: OTTO, message: 'reply', score: 0, reply_count: 0 }], 'votes gone and the score recomputed');
{ id: 1, a: null, message: '[deleted]', score: 0, reply_count: 1 }, { id: 2, a: OTTO, message: 'reply', score: 0, reply_count: 0 }], 'votes gone and the score recomputed');
- assert.deepStrictEqual(all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');
- assert.deepStrictEqual(all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);
- assert.strictEqual(all('SELECT * FROM post_reactions').length + all('SELECT * FROM paste_likes').length, 0);
- assert.strictEqual(db.prepare('SELECT likes FROM pastes WHERE id = 3').get().likes, 0, 'the like count follows');
- assert.deepStrictEqual(all('SELECT subject_id AS s FROM game_progress'), [{ s: OTTO }]);
- assert.strictEqual(all('SELECT * FROM network_blocks').length, 0, 'blocks both ways');
- assert.strictEqual(db.prepare('SELECT created_by FROM spaces WHERE id = 901').get().created_by, null, 'the space stays without her');
+ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');
+ assert.deepStrictEqual(await all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);
+ assert.strictEqual((await all('SELECT * FROM post_reactions')).length + (await all('SELECT * FROM paste_likes')).length, 0);
+ assert.strictEqual((await db.prepare('SELECT likes FROM pastes WHERE id = 3').get()).likes, 0, 'the like count follows');
+ assert.deepStrictEqual(await all('SELECT subject_id AS s FROM game_progress'), [{ s: OTTO }]);
+ assert.strictEqual((await all('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
+ assert.strictEqual((await db.prepare('SELECT created_by FROM spaces WHERE id = 901').get()).created_by, null, 'the space stays without her');
r = await post(del);
r = await post(del);
assert.deepStrictEqual([r.status, r.json.outcome], [200, 'confirmed'], 'the retry confirms without erasing again');
assert.deepStrictEqual([r.status, r.json.outcome], [200, 'confirmed'], 'the retry confirms without erasing again');
@@ -80,7 +80,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
@@ -80,7 +80,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
}
}
const r = await post(20);
const r = await post(20);
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
- const stored = t.db.prepare("SELECT COUNT(*) AS n FROM comments c JOIN comment_threads th ON th.id = c.thread_id WHERE th.ref_service = 'live' AND th.ref_type = 'stream' AND th.ref_id = '42'").get().n;
+ const stored = (await t.db.prepare("SELECT COUNT(*) AS n FROM comments c JOIN comment_threads th ON th.id = c.thread_id WHERE th.ref_service = 'live' AND th.ref_type = 'stream' AND th.ref_id = '42'").get()).n;
assert.strictEqual(stored, 20, 'nothing stored');
assert.strictEqual(stored, 20, 'nothing stored');
assert.strictEqual((await call(`/api/v1/comments/threads/${thread.access_id || thread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } })).status, 201, 'another person still comments');
assert.strictEqual((await call(`/api/v1/comments/threads/${thread.access_id || thread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } })).status, 201, 'another person still comments');
});
});
@@ -61,8 +61,8 @@ const SECRET = `whsec_${'cd'.repeat(32)}`;
@@ -61,8 +61,8 @@ const SECRET = `whsec_${'cd'.repeat(32)}`;
const bad = block(alex.subject_id, sam.subject_id, false, 9); bad.payload.blocked = 'sam';
const bad = block(alex.subject_id, sam.subject_id, false, 9); bad.payload.blocked = 'sam';
assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');
assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');
const blocks = require('../server/identity/blocks');
const blocks = require('../server/identity/blocks');
- assert.ok(blocks.hasBlocked(t.db, alex.subject_id, sam.subject_id));
- assert.ok(!blocks.hasBlocked(t.db, sam.subject_id, alex.subject_id), 'one direction');
+ assert.ok(await blocks.hasBlocked(t.db, alex.subject_id, sam.subject_id));
+ assert.ok(!await blocks.hasBlocked(t.db, sam.subject_id, alex.subject_id), 'one direction');
});
});
await check('forum: no reply in a thread whose author blocked you (API problem and no-JS form); others reply', async () => {
await check('forum: no reply in a thread whose author blocked you (API problem and no-JS form); others reply', async () => {
@@ -100,18 +100,18 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
@@ -100,18 +100,18 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
{ key: 'nostatus', title: 'No status', summary: 'y' },
{ key: 'nostatus', title: 'No status', summary: 'y' },
];
];
const quiet = { warn() {} };
const quiet = { warn() {} };
- assert.deepStrictEqual(syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });
+ assert.deepStrictEqual(await syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });
let th = (await call('/api/v1/spaces/roadmap/threads?category=features')).json().threads.find((x) => x.title === 'Calls in Chat');
let th = (await call('/api/v1/spaces/roadmap/threads?category=features')).json().threads.find((x) => x.title === 'Calls in Chat');
assert.strictEqual(th.author.display_name, 'OpenVibe'); assert.strictEqual(th.origin, 'system');
assert.strictEqual(th.author.display_name, 'OpenVibe'); assert.strictEqual(th.origin, 'system');
- assert.deepStrictEqual(syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });
+ assert.deepStrictEqual(await syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });
const next = [{ ...items[0], status: 'in_progress', title: 'Voice and video calls in Chat', summary: 'Calls move to Chat. Started.' }];
const next = [{ ...items[0], status: 'in_progress', title: 'Voice and video calls in Chat', summary: 'Calls move to Chat. Started.' }];
- assert.deepStrictEqual(syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });
+ assert.deepStrictEqual(await syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });
const page = (await call(`/api/v1/spaces/roadmap/threads/${th.slug}`)).json();
const page = (await call(`/api/v1/spaces/roadmap/threads/${th.slug}`)).json();
assert.strictEqual(page.thread.title, 'Voice and video calls in Chat'); assert.strictEqual(page.thread.status, 'in_progress');
assert.strictEqual(page.thread.title, 'Voice and video calls in Chat'); assert.strictEqual(page.thread.status, 'in_progress');
assert.strictEqual(page.posts[0].body_markdown, 'Calls move to Chat. Started.'); assert.strictEqual(page.posts[0].revision, 2, 'the summary edit keeps the history');
assert.strictEqual(page.posts[0].body_markdown, 'Calls move to Chat. Started.'); assert.strictEqual(page.posts[0].revision, 2, 'the summary edit keeps the history');
- const file = syncFromFile(db, { log: quiet });
+ const file = await syncFromFile(db, { log: quiet });
assert.ok(file && file.created >= 10 && file.skipped === 0, JSON.stringify(file));
assert.ok(file && file.created >= 10 && file.skipped === 0, JSON.stringify(file));
- assert.deepStrictEqual(syncFromFile(db, { log: quiet }).updated, 0, 'the shipped file syncs to a fixed point');
+ assert.deepStrictEqual((await syncFromFile(db, { log: quiet })).updated, 0, 'the shipped file syncs to a fixed point');
});
});
await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {
await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {
@@ -76,8 +76,8 @@ function startStubChat() {
@@ -76,8 +76,8 @@ function startStubChat() {
const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });
const bossJwt = net.sign({ id: 1, subject_id: bossSubject, username: 'boss', display_name: 'Boss', role: 'admin' });
// Alex owns the Showcase space (its creator) and the rooms night-owls and secret; Sam owns sams-room; Boss (staff) owns staff-room.
// Alex owns the Showcase space (its creator) and the rooms night-owls and secret; Sam owns sams-room; Boss (staff) owns staff-room.
- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);
- t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);
+ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(alex.subject_id);
+ await t.db.prepare("UPDATE spaces SET members_only_owner = ? WHERE slug = 'off-topic'").run(sam.subject_id);
chat.addRoom('night-owls', { name: 'Night Owls', owner: alex.subject_id });
chat.addRoom('night-owls', { name: 'Night Owls', owner: alex.subject_id });
chat.addRoom('secret', { name: 'Secret', owner: sam.subject_id, visibility: 'private', members: [sam.subject_id] });
chat.addRoom('secret', { name: 'Secret', owner: sam.subject_id, visibility: 'private', members: [sam.subject_id] });
chat.addRoom('stage', { name: 'Stage', kind: 'call', owner: sam.subject_id });
chat.addRoom('stage', { name: 'Stage', kind: 'call', owner: sam.subject_id });
@@ -85,7 +85,7 @@ function startStubChat() {
@@ -85,7 +85,7 @@ function startStubChat() {
chat.addRoom('staff-room', { name: 'Staff Room', owner: bossSubject });
chat.addRoom('staff-room', { name: 'Staff Room', owner: bossSubject });
const api = (method, path, jwt, body) => t.get(`/api/v1/spaces${path}`, { method, headers: { ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
const api = (method, path, jwt, body) => t.get(`/api/v1/spaces${path}`, { method, headers: { ...(jwt ? { authorization: `Bearer ${jwt}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
const form = (jwt, path, fields, origin = 'https://openvibe.community') => t.get(path, { method: 'POST', cookies: [`ov_token=${jwt}`], headers: { 'content-type': 'application/x-www-form-urlencoded', origin }, body: new URLSearchParams(fields).toString() });
const form = (jwt, path, fields, origin = 'https://openvibe.community') => t.get(path, { method: 'POST', cookies: [`ov_token=${jwt}`], headers: { 'content-type': 'application/x-www-form-urlencoded', origin }, body: new URLSearchParams(fields).toString() });
- const rows = () => t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();
+ const rows = async () => await t.db.prepare('SELECT * FROM space_chat_rooms ORDER BY space_id').all();
await check('only the space\'s owner or staff attach, and only as themselves', async () => {
await check('only the space\'s owner or staff attach, and only as themselves', async () => {
const before = chat.calls.length;
const before = chat.calls.length;
@@ -98,7 +98,7 @@ function startStubChat() {
@@ -98,7 +98,7 @@ function startStubChat() {
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: '../../admin' });
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: '../../admin' });
assert.deepStrictEqual([r.status, r.json().code], [400, 'chat_room.invalid']);
assert.deepStrictEqual([r.status, r.json().code], [400, 'chat_room.invalid']);
assert.strictEqual(chat.calls.length, before, 'Chat was not asked for any of these');
assert.strictEqual(chat.calls.length, before, 'Chat was not asked for any of these');
- assert.deepStrictEqual(rows(), []);
+ assert.deepStrictEqual(await rows(), []);
});
});
await check('the owner attaches by link; Chat is asked with their own token; again is a no-op', async () => {
await check('the owner attaches by link; Chat is asked with their own token; again is a no-op', async () => {
@@ -109,8 +109,8 @@ function startStubChat() {
@@ -109,8 +109,8 @@ function startStubChat() {
assert.deepStrictEqual([call.method, call.url, call.auth, call.body], ['POST', '/api/chat/rooms/night-owls/attachments', `Bearer ${alexJwt}`, { service: 'community', resource: 'showcase', title: 'Showcase' }]);
assert.deepStrictEqual([call.method, call.url, call.auth, call.body], ['POST', '/api/chat/rooms/night-owls/attachments', `Bearer ${alexJwt}`, { service: 'community', resource: 'showcase', title: 'Showcase' }]);
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
assert.deepStrictEqual([r.status, r.json().created], [200, false]);
assert.deepStrictEqual([r.status, r.json().created], [200, false]);
- assert.strictEqual(rows().length, 1);
- assert.strictEqual(rows()[0].attached_by, alex.subject_id);
+ assert.strictEqual((await rows()).length, 1);
+ assert.strictEqual((await rows())[0].attached_by, alex.subject_id);
assert.strictEqual(chat.attachments.size, 1);
assert.strictEqual(chat.attachments.size, 1);
assert.strictEqual((await api('GET', '/showcase')).json().space.chat_room.slug, 'night-owls', 'the space says which room');
assert.strictEqual((await api('GET', '/showcase')).json().space.chat_room.slug, 'night-owls', 'the space says which room');
});
});
@@ -131,7 +131,7 @@ function startStubChat() {
@@ -131,7 +131,7 @@ function startStubChat() {
const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });
const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });
assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);
assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);
chat.state.down = false;
chat.state.down = false;
- assert.strictEqual(rows()[0].room_slug, 'night-owls');
+ assert.strictEqual((await rows())[0].room_slug, 'night-owls');
});
});
await check('the space page links the room; its owner gets the forms; others only the link', async () => {
await check('the space page links the room; its owner gets the forms; others only the link', async () => {
@@ -160,13 +160,13 @@ function startStubChat() {
@@ -160,13 +160,13 @@ function startStubChat() {
assert.deepStrictEqual([r.status, r.json().created, r.json().chat_room.slug], [201, true, 'staff-room']);
assert.deepStrictEqual([r.status, r.json().created, r.json().chat_room.slug], [201, true, 'staff-room']);
assert.ok(chat.calls.some((c) => c.method === 'DELETE' && c.url === '/api/chat/rooms/night-owls/attachments/community/showcase'));
assert.ok(chat.calls.some((c) => c.method === 'DELETE' && c.url === '/api/chat/rooms/night-owls/attachments/community/showcase'));
assert.strictEqual(chat.attachments.has('night-owls|showcase'), true, 'Chat keeps the old side when the caller may not remove it');
assert.strictEqual(chat.attachments.has('night-owls|showcase'), true, 'Chat keeps the old side when the caller may not remove it');
- assert.strictEqual(rows().find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);
+ assert.strictEqual((await rows()).find((x) => x.room_slug === 'staff-room' && x.attached_by === bossSubject) !== undefined, true);
// The owner puts theirs back and detaches it without JavaScript: both sides go.
// The owner puts theirs back and detaches it without JavaScript: both sides go.
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});
const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});
assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);
assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);
- assert.ok(!rows().some((x) => x.room_slug === 'night-owls'));
+ assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));
assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');
assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');
r = await api('DELETE', '/showcase/chat-room', alexJwt);
r = await api('DELETE', '/showcase/chat-room', alexJwt);
assert.deepStrictEqual([r.status, r.json().detached], [200, false], 'detaching twice is fine');
assert.deepStrictEqual([r.status, r.json().detached], [200, false], 'detaching twice is fine');
@@ -60,7 +60,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -60,7 +60,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(b.json().created, false);
assert.strictEqual(b.json().created, false);
const other = await resolve({ service: 'live', type: 'channel', id: '123' });
const other = await resolve({ service: 'live', type: 'channel', id: '123' });
assert.notStrictEqual(other.json().thread.id, vod.id);
assert.notStrictEqual(other.json().thread.id, vod.id);
- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'stream' AND ref_id = '123'").get().c, 1);
+ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM comment_threads WHERE ref_service = 'live' AND ref_type = 'stream' AND ref_id = '123'").get()).c, 1);
});
});
await check('resolve: browsers only for allowlisted types; services with comment.write for any; labels only from services', async () => {
await check('resolve: browsers only for allowlisted types; services with comment.write for any; labels only from services', async () => {
@@ -109,7 +109,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -109,7 +109,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(c.anon_name, null);
assert.strictEqual(c.anon_name, null);
assert.strictEqual(c.origin, 'user');
assert.strictEqual(c.origin, 'user');
assert.strictEqual(c.can_delete, true);
assert.strictEqual(c.can_delete, true);
- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, 1);
+ assert.strictEqual((await t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id)).comment_count, 1);
assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);
assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);
@@ -194,20 +194,20 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -194,20 +194,20 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await vote(-1, { token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).json().score, 0);
assert.strictEqual((await vote(-1, { token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).json().score, 0);
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`, { cookie: alexJwt })).json();
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`, { cookie: alexJwt })).json();
assert.strictEqual(page.comments.find((x) => x.id === c.id).my_vote, 1);
assert.strictEqual(page.comments.find((x) => x.id === c.id).my_vote, 1);
- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(c.id).n, 2);
+ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(c.id)).n, 2);
});
});
await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {
await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {
const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;
const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;
await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });
await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });
- const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count;
+ const before = (await t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id)).comment_count;
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);
const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });
const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });
assert.strictEqual(ok.status, 200, ok.text);
assert.strictEqual(ok.status, 200, ok.text);
- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, before - 1);
- assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id).message, '', 'scrubbed');
+ assert.strictEqual((await t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id)).comment_count, before - 1);
+ assert.strictEqual((await t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id)).message, '', 'scrubbed');
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const tomb = page.comments.find((c) => c.id === top.id);
const tomb = page.comments.find((c) => c.id === top.id);
assert.ok(tomb && tomb.deleted && tomb.message === null && tomb.author === null);
assert.ok(tomb && tomb.deleted && tomb.message === null && tomb.author === null);
@@ -264,7 +264,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -264,7 +264,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(c.author.is_ai, true);
assert.strictEqual(c.author.is_ai, true);
assert.strictEqual(c.display_name, 'OpenVibe AI');
assert.strictEqual(c.display_name, 'OpenVibe AI');
assert.strictEqual(c.anon_name, null);
assert.strictEqual(c.anon_name, null);
- assert.strictEqual(t.db.prepare('SELECT author_subject FROM comments WHERE id = ?').get(c.id).author_subject, null);
+ assert.strictEqual((await t.db.prepare('SELECT author_subject FROM comments WHERE id = ?').get(c.id)).author_subject, null);
assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');
assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');
});
});
@@ -289,7 +289,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -289,7 +289,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await call(`/api/v1/comments/threads/${th.access_id}`, { token: svcW })).status, 200, 'and by access id');
assert.strictEqual((await call(`/api/v1/comments/threads/${th.access_id}`, { token: svcW })).status, 200, 'and by access id');
// Browsers walking ids 1..N find nothing, signed in or not, staff included.
// Browsers walking ids 1..N find nothing, signed in or not, staff included.
- const max = t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get().m;
+ const max = (await t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get()).m;
for (let id = 1; id <= max; id++) {
for (let id = 1; id <= max; id++) {
for (const who of [{}, { cookie: samJwt }, { token: alexJwt }, { cookie: adminJwt }]) {
for (const who of [{}, { cookie: samJwt }, { token: alexJwt }, { cookie: adminJwt }]) {
const r = await call(`/api/v1/comments/threads/${id}`, who);
const r = await call(`/api/v1/comments/threads/${id}`, who);
@@ -314,34 +314,10 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -314,34 +314,10 @@ const { checkCapability } = require('../server/identity/capabilities');
const again = await resolve({ service: 'live', type: 'stream', id: '123' }, { cookie: samJwt });
const again = await resolve({ service: 'live', type: 'stream', id: '123' }, { cookie: samJwt });
assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');
assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');
assert.match(vod.id, /^cth_/);
assert.match(vod.id, /^cth_/);
- const all = t.db.prepare('SELECT access_id FROM comment_threads').all().map((r) => r.access_id);
+ const all = (await t.db.prepare('SELECT access_id FROM comment_threads').all()).map((r) => r.access_id);
assert.ok(all.every(Boolean) && new Set(all).size === all.length, 'every thread has its own');
assert.ok(all.every(Boolean) && new Set(all).size === all.length, 'every thread has its own');
});
});
- await check('threads made before access ids get one on boot (migration is idempotent)', async () => {
- const fs = require('fs'), os = require('os'), path = require('path');
- const Database = require('better-sqlite3');
- const { openDb } = require('../server/db');
- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-mig-')), 'old.db');
- const old = new Database(file);
- old.exec(`CREATE TABLE comment_threads (id INTEGER PRIMARY KEY AUTOINCREMENT, ref_service TEXT NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL, ref_label TEXT,
- visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')), comment_count INTEGER NOT NULL DEFAULT 0, created_by TEXT,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (ref_service, ref_type, ref_id))`);
- for (let i = 0; i < 5; i++) old.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', ?)").run(String(i));
- old.close();
- const db = openDb(file);
- const rows = db.prepare('SELECT id, access_id FROM comment_threads').all();
- assert.strictEqual(rows.length, 5);
- for (const r of rows) assert.match(r.access_id, /^cth_[A-Za-z0-9_-]{22}$/);
- assert.strictEqual(new Set(rows.map((r) => r.access_id)).size, 5);
- db.close();
- const db2 = openDb(file);
- assert.deepStrictEqual(db2.prepare('SELECT id, access_id FROM comment_threads').all(), rows, 'a second boot changes nothing');
- assert.throws(() => db2.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?').run(rows[0].access_id, rows[1].id), /UNIQUE/);
- db2.close();
- fs.rmSync(path.dirname(file), { recursive: true, force: true });
- });
-
await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {
await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {
const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });
const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });
assert.strictEqual(pre.status, 204);
assert.strictEqual(pre.status, 204);
@@ -420,7 +396,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -420,7 +396,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.ok(ok.json().comment.edited_at, 'edited_at set');
assert.ok(ok.json().comment.edited_at, 'edited_at set');
const byBrowser = await patch({ message: 'typo here!' }, { cookie: alexJwt });
const byBrowser = await patch({ message: 'typo here!' }, { cookie: alexJwt });
assert.strictEqual(byBrowser.status, 200, 'the author from a browser too');
assert.strictEqual(byBrowser.status, 200, 'the author from a browser too');
- assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(c.id).message, 'typo here!');
+ assert.strictEqual((await t.db.prepare('SELECT message FROM comments WHERE id = ?').get(c.id)).message, 'typo here!');
await callL(`/api/v1/comments/${c.id}`, { method: 'DELETE', ...asLive(alex.subject_id) });
await callL(`/api/v1/comments/${c.id}`, { method: 'DELETE', ...asLive(alex.subject_id) });
assert.strictEqual((await patch({ message: 'back from the dead' }, asLive(alex.subject_id))).status, 404);
assert.strictEqual((await patch({ message: 'back from the dead' }, asLive(alex.subject_id))).status, 404);
});
});
@@ -464,25 +440,6 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -464,25 +440,6 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await callL(`/c/${liveVod.access_id}`)).status, 200);
assert.strictEqual((await callL(`/c/${liveVod.access_id}`)).status, 200);
});
});
- await check('comments.edited_at is added to databases made before it (idempotent)', async () => {
- const fs = require('fs'), os = require('os'), path = require('path');
- const Database = require('better-sqlite3');
- const { openDb } = require('../server/db');
- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-edit-')), 'old.db');
- const old = new Database(file);
- old.exec(`CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, parent_id INTEGER, author_subject TEXT, anon_name TEXT,
- origin TEXT NOT NULL DEFAULT 'user', message TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, upvotes INTEGER NOT NULL DEFAULT 0, downvotes INTEGER NOT NULL DEFAULT 0,
- reply_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, deleted_by TEXT)`);
- old.prepare("INSERT INTO comments (thread_id, message) VALUES (1, 'kept')").run();
- old.close();
- const db = openDb(file);
- assert.ok(db.prepare('PRAGMA table_info(comments)').all().some((c) => c.name === 'edited_at'));
- assert.strictEqual(db.prepare('SELECT message, edited_at FROM comments').get().message, 'kept');
- db.close();
- openDb(file).close();
- fs.rmSync(path.dirname(file), { recursive: true, force: true });
- });
-
await t.close();
await t.close();
done();
done();
})();
})();
@@ -6,101 +6,103 @@
@@ -6,101 +6,103 @@
*/
*/
const assert = require('assert');
const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const events = require('../server/events');
const events = require('../server/events');
const pastes = require('../server/pastes/store');
const pastes = require('../server/pastes/store');
const forum = require('../server/forum/store');
const forum = require('../server/forum/store');
const comments = require('../server/comments/store');
const comments = require('../server/comments/store');
-const db = openDb(':memory:');
-// A relay that never publishes during the test: the rows stay in the outbox for inspection.
-events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });
-const queued = () => db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope));
-const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
-const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };
+(async () => {
+ const db = await testDb();
+ // A relay that never publishes during the test: the rows stay in the outbox for inspection.
+ events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });
+ const queued = async () => (await db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : r.envelope));
+ const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };
-let n = 0;
-function check(name, fn) { fn(); n++; console.log(` ✓ ${name}`); }
+ let n = 0;
+ async function check(name, fn) { await fn(); n++; console.log(` ✓ ${name}`); }
-check('a paste create, edit, visibility change and delete each queue one event, never the content', () => {
- const p = pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'secret title', content: 'secret body', language: 'text', visibility: 'public' });
- pastes.updatePaste(db, p.id, { content: 'edited body' }, USR);
- pastes.setVisibility(db, p.id, 'unlisted');
- pastes.softDelete(db, p.id);
- const evs = queued();
- assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.paste.created', 'community.paste.updated', 'community.paste.updated', 'community.paste.deleted']);
- evs.forEach(ok);
- assert.ok(!JSON.stringify(evs).includes('secret'), 'no title or content in any event');
- assert.strictEqual(evs[0].visibility, 'public');
- assert.strictEqual(evs[0].payload.url, 'https://openvibe.community/p/amber-fox-42'.replace('https://openvibe.community', require('../server/config').baseUrl));
- assert.deepStrictEqual(evs[1].payload.changed, ['content']);
- assert.strictEqual(evs[2].visibility, 'internal', 'an unlisted paste is not a public event');
- assert.strictEqual(evs[2].payload.url, null);
- assert.deepStrictEqual(evs[0].actor, { type: 'user', id: USR });
-});
+ await check('a paste create, edit, visibility change and delete each queue one event, never the content', async () => {
+ const p = await pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'secret title', content: 'secret body', language: 'text', visibility: 'public' });
+ await pastes.updatePaste(db, p.id, { content: 'edited body' }, USR);
+ await pastes.setVisibility(db, p.id, 'unlisted');
+ await pastes.softDelete(db, p.id);
+ const evs = await queued();
+ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.paste.created', 'community.paste.updated', 'community.paste.updated', 'community.paste.deleted']);
+ evs.forEach(ok);
+ assert.ok(!JSON.stringify(evs).includes('secret'), 'no title or content in any event');
+ assert.strictEqual(evs[0].visibility, 'public');
+ assert.strictEqual(evs[0].payload.url, 'https://openvibe.community/p/amber-fox-42'.replace('https://openvibe.community', require('../server/config').baseUrl));
+ assert.deepStrictEqual(evs[1].payload.changed, ['content']);
+ assert.strictEqual(evs[2].visibility, 'internal', 'an unlisted paste is not a public event');
+ assert.strictEqual(evs[2].payload.url, null);
+ assert.deepStrictEqual(evs[0].actor, { type: 'user', id: USR });
+ });
-check('a write that fails queues nothing (the event is in its transaction)', () => {
- const before = queued().length;
- assert.throws(() => pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, type: 'paste', content: 'dup', visibility: 'public' }), /UNIQUE/);
- assert.strictEqual(queued().length, before);
-});
+ await check('a write that fails queues nothing (the event is in its transaction)', async () => {
+ const before = (await queued()).length;
+ await assert.rejects(async () => await pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, type: 'paste', content: 'dup', visibility: 'public' }), /duplicate key/);
+ assert.strictEqual((await queued()).length, before);
+ });
-check('threads and posts: public spaces only are public events; staff spaces are members-only', () => {
- db.prepare("INSERT OR IGNORE INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff')").run();
- const general = db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get().id;
- const staff = db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get().id;
- const before = queued().length;
- const { thread } = forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: 'first post body' });
- forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });
- forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden body' });
- const evs = queued().slice(before);
- assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);
- evs.forEach(ok);
- assert.strictEqual(evs[0].visibility, 'public');
- assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));
- assert.strictEqual(evs[2].visibility, 'internal');
- assert.strictEqual(evs[2].payload.visibility, 'members');
- assert.strictEqual(evs[2].payload.url, null);
- assert.ok(!JSON.stringify(evs).includes('body'), 'no post body');
-});
+ await check('threads and posts: public spaces only are public events; staff spaces are members-only', async () => {
+ await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff') ON CONFLICT DO NOTHING").run();
+ const general = (await db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get()).id;
+ const staff = (await db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get()).id;
+ const before = (await queued()).length;
+ const { thread } = await forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: 'first post body' });
+ await forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });
+ await forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden body' });
+ const evs = (await queued()).slice(before);
+ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);
+ evs.forEach(ok);
+ assert.strictEqual(evs[0].visibility, 'public');
+ assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));
+ assert.strictEqual(evs[2].visibility, 'internal');
+ assert.strictEqual(evs[2].payload.visibility, 'members');
+ assert.strictEqual(evs[2].payload.url, null);
+ assert.ok(!JSON.stringify(evs).includes('body'), 'no post body');
+ });
-check('a comment on a Live VOD queues community.comment.created with its ref', () => {
- const before = queued().length;
- const t = comments.resolveThread(db, { service: 'live', type: 'vod', id: '906' }).thread;
- comments.insertComment(db, { thread_id: t.id, author_subject: USR, message: 'nice stream' });
- const evs = queued().slice(before);
- assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.comment.created']);
- evs.forEach(ok);
- assert.deepStrictEqual(evs[0].payload.ref, { service: 'live', type: 'vod', id: '906' });
- assert.ok(!JSON.stringify(evs).includes('nice stream'));
-});
+ await check('a comment on a Live VOD queues community.comment.created with its ref', async () => {
+ const before = (await queued()).length;
+ const t = (await comments.resolveThread(db, { service: 'live', type: 'vod', id: '906' })).thread;
+ await comments.insertComment(db, { thread_id: t.id, author_subject: USR, message: 'nice stream' });
+ const evs = (await queued()).slice(before);
+ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.comment.created']);
+ evs.forEach(ok);
+ assert.deepStrictEqual(evs[0].payload.ref, { service: 'live', type: 'vod', id: '906' });
+ assert.ok(!JSON.stringify(evs).includes('nice stream'));
+ });
-check('staff actions on someone else\'s content go to the moderation audit log (ADR-022); an owner\'s own do not', () => {
- const { createPasteService } = require('../server/pastes/service');
- const svc = createPasteService({ db });
- const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
- pastes.insertPaste(db, { slug: 'mod-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'secret words', language: 'text', visibility: 'public' });
- pastes.insertPaste(db, { slug: 'own-paste-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
- const before = queued().length;
- svc.remove({ kind: 'user', subject: STAFF, staff: true }, 'mod-target-1');
- svc.remove({ kind: 'user', subject: USR }, 'own-paste-1');
- const evs = queued().slice(before);
- const mod = evs.filter((e) => e.event_type === 'community.moderation.action');
- assert.strictEqual(mod.length, 1, 'only the staff delete is audited');
- ok(mod[0]);
- assert.deepStrictEqual(mod[0].payload.target, { type: 'paste', id: 'mod-target-1', owner_subject: USR });
- assert.strictEqual(mod[0].payload.actor_subject, STAFF);
- assert.deepStrictEqual(mod[0].actor, { type: 'user', id: STAFF });
- assert.ok(!JSON.stringify(mod).includes('secret words'), 'never the content');
- pastes.insertPaste(db, { slug: 'bulk-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
- const bulk = svc.bulk({ slugs: ['bulk-target-1', 'nope'], action: 'private' }, { kind: 'user', subject: STAFF, staff: true });
- assert.deepStrictEqual([bulk.done, bulk.skipped], [1, 1]);
- const last = queued().slice(-1)[0];
- assert.strictEqual(last.event_type, 'community.moderation.action');
- assert.deepStrictEqual(last.payload.details, { bulk_action: 'private', done: bulk.done, skipped: bulk.skipped });
- ok(last);
-});
+ await check('staff actions on someone else\'s content go to the moderation audit log (ADR-022); an owner\'s own do not', async () => {
+ const { createPasteService } = require('../server/pastes/service');
+ const svc = createPasteService({ db });
+ const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+ await pastes.insertPaste(db, { slug: 'mod-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'secret words', language: 'text', visibility: 'public' });
+ await pastes.insertPaste(db, { slug: 'own-paste-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
+ const before = (await queued()).length;
+ await svc.remove({ kind: 'user', subject: STAFF, staff: true }, 'mod-target-1');
+ await svc.remove({ kind: 'user', subject: USR }, 'own-paste-1');
+ const evs = (await queued()).slice(before);
+ const mod = evs.filter((e) => e.event_type === 'community.moderation.action');
+ assert.strictEqual(mod.length, 1, 'only the staff delete is audited');
+ ok(mod[0]);
+ assert.deepStrictEqual(mod[0].payload.target, { type: 'paste', id: 'mod-target-1', owner_subject: USR });
+ assert.strictEqual(mod[0].payload.actor_subject, STAFF);
+ assert.deepStrictEqual(mod[0].actor, { type: 'user', id: STAFF });
+ assert.ok(!JSON.stringify(mod).includes('secret words'), 'never the content');
+ await pastes.insertPaste(db, { slug: 'bulk-target-1', owner_subject: USR, origin: 'user', type: 'paste', title: 't', content: 'x', language: 'text', visibility: 'public' });
+ const bulk = await svc.bulk({ slugs: ['bulk-target-1', 'nope'], action: 'private' }, { kind: 'user', subject: STAFF, staff: true });
+ assert.deepStrictEqual([bulk.done, bulk.skipped], [1, 1]);
+ const last = (await queued()).slice(-1)[0];
+ assert.strictEqual(last.event_type, 'community.moderation.action');
+ assert.deepStrictEqual(last.payload.details, { bulk_action: 'private', done: bulk.done, skipped: bulk.skipped });
+ ok(last);
+ });
-events._reset();
-console.log(`community events: ${n} checks passed`);
-process.exit(0);
+ events._reset();
+ console.log(`community events: ${n} checks passed`);
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -16,7 +16,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -16,7 +16,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
(async () => {
(async () => {
const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
// These pages are the feed style (render/forum.js): General is a forum board by default, so it becomes a feed with votes here.
// These pages are the feed style (render/forum.js): General is a forum board by default, so it becomes a feed with votes here.
- t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();
+ await t.db.prepare("UPDATE spaces SET style = 'feed', votes = 1 WHERE slug = 'general'").run();
const net = t.network;
const net = t.network;
const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
@@ -130,8 +130,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -130,8 +130,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
});
});
await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {
await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {
- const space = forumStore.getSpace(t.db, 'showcase');
- for (let i = 1; i <= 27; i++) forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });
+ const space = await forumStore.getSpace(t.db, 'showcase');
+ for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });
const p1 = await t.get('/s/showcase?sort=new');
const p1 = await t.get('/s/showcase?sort=new');
assert.strictEqual(p1.status, 200);
assert.strictEqual(p1.status, 200);
has(p1.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new">');
has(p1.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new">');
@@ -149,9 +149,9 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -149,9 +149,9 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
});
});
await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {
await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {
- t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
- const ins = forumStore.getSpace(t.db, 'insiders');
- forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });
+ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
+ const ins = await forumStore.getSpace(t.db, 'insiders');
+ await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });
const anon = await t.get('/s/insiders');
const anon = await t.get('/s/insiders');
assert.strictEqual(anon.status, 303);
assert.strictEqual(anon.status, 303);
assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');
assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');
@@ -171,8 +171,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -171,8 +171,8 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
});
});
await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {
await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {
- const space = forumStore.getSpace(t.db, 'feedback');
- forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });
+ const space = await forumStore.getSpace(t.db, 'feedback');
+ await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });
const r = await t.get('/s/feedback/t/weekly-summary');
const r = await t.get('/s/feedback/t/weekly-summary');
has(r.text, 'OpenVibe AI');
has(r.text, 'OpenVibe AI');
has(r.text, '<span class="badge badge-ai"');
has(r.text, '<span class="badge badge-ai"');
@@ -26,7 +26,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -26,7 +26,7 @@ const { boot, check, done } = require('./helpers/app');
if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
};
};
- const pasteRow = (slug, extra = {}) => t.db.prepare('INSERT INTO pastes (slug, owner_subject, title, content, language, visibility, burn_after_read) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ const pasteRow = async (slug, extra = {}) => await t.db.prepare('INSERT INTO pastes (slug, owner_subject, title, content, language, visibility, burn_after_read) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(slug, alex.subject_id, extra.title || 'Snippet', extra.content || 'const a = 1;\nconsole.log(a);', 'javascript', extra.visibility || 'public', extra.burn ? 1 : 0);
.run(slug, alex.subject_id, extra.title || 'Snippet', extra.content || 'const a = 1;\nconsole.log(a);', 'javascript', extra.visibility || 'public', extra.burn ? 1 : 0);
let topic;
let topic;
@@ -81,10 +81,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -81,10 +81,10 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));
assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));
r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));
assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));
- t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();
+ await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();
assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');
assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');
assert.deepStrictEqual((await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0].reactions, []);
assert.deepStrictEqual((await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[0].reactions, []);
- t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();
+ await t.db.prepare("UPDATE spaces SET reactions = 1 WHERE slug = 'general'").run();
});
});
await check('quote: ?quote= fills the reply box with the quoted post', async () => {
await check('quote: ?quote= fills the reply box with the quoted post', async () => {
@@ -94,7 +94,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -94,7 +94,7 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('pastes on posts: public and unlisted as cards; private and burn-after-read refused', async () => {
await check('pastes on posts: public and unlisted as cards; private and burn-after-read refused', async () => {
- pasteRow('snip1'); pasteRow('snip2', { visibility: 'unlisted', title: 'Hidden-ish' }); pasteRow('secret1', { visibility: 'private' }); pasteRow('burn1', { burn: true });
+ await pasteRow('snip1'); await pasteRow('snip2', { visibility: 'unlisted', title: 'Hidden-ish' }); await pasteRow('secret1', { visibility: 'private' }); await pasteRow('burn1', { burn: true });
let r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'look at these', pastes: 'https://openvibe.community/p/snip1 snip2' } });
let r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'look at these', pastes: 'https://openvibe.community/p/snip1 snip2' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);
assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);
@@ -102,7 +102,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -102,7 +102,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);
const html = (await call('/s/general/t/welcome-thread')).text;
const html = (await call('/s/general/t/welcome-thread')).text;
assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));
assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));
- t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();
+ await t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();
const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');
const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');
assert.deepStrictEqual(after.pastes.map((p) => p.slug), ['snip1'], 'a paste made private drops out');
assert.deepStrictEqual(after.pastes.map((p) => p.slug), ['snip1'], 'a paste made private drops out');
});
});
@@ -80,7 +80,7 @@ const forumStore = require('../server/forum/store');
@@ -80,7 +80,7 @@ const forumStore = require('../server/forum/store');
assert.strictEqual(r.json().thread.origin, 'ai');
assert.strictEqual(r.json().thread.origin, 'ai');
assert.strictEqual(r.json().thread.author.is_ai, true);
assert.strictEqual(r.json().thread.author.is_ai, true);
assert.strictEqual(r.json().thread.author.subject, null);
assert.strictEqual(r.json().thread.author.subject, null);
- assert.strictEqual(t.db.prepare('SELECT author_subject FROM threads WHERE id = ?').get(r.json().thread.id).author_subject, null);
+ assert.strictEqual((await t.db.prepare('SELECT author_subject FROM threads WHERE id = ?').get(r.json().thread.id)).author_subject, null);
});
});
await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {
await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {
@@ -122,7 +122,7 @@ const forumStore = require('../server/forum/store');
@@ -122,7 +122,7 @@ const forumStore = require('../server/forum/store');
const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });
const vote = (value, cookie) => call('/api/v1/spaces/general/threads/hello-world/votes', { method: 'POST', cookie, json: { value } });
const off = await vote(1, samJwt);
const off = await vote(1, samJwt);
assert.strictEqual(off.status, 403, 'General is a forum-style board: votes are off'); assert.strictEqual(off.json().code, 'space.votes_off');
assert.strictEqual(off.status, 403, 'General is a forum-style board: votes are off'); assert.strictEqual(off.json().code, 'space.votes_off');
- t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();
+ await t.db.prepare("UPDATE spaces SET votes = 1 WHERE slug = 'general'").run();
assert.strictEqual((await vote(1, samJwt)).json().score, 1);
assert.strictEqual((await vote(1, samJwt)).json().score, 1);
assert.strictEqual((await vote(1, alexJwt)).json().score, 2);
assert.strictEqual((await vote(1, alexJwt)).json().score, 2);
assert.strictEqual((await vote(-1, samJwt)).json().score, 0);
assert.strictEqual((await vote(-1, samJwt)).json().score, 0);
@@ -133,31 +133,31 @@ const forumStore = require('../server/forum/store');
@@ -133,31 +133,31 @@ const forumStore = require('../server/forum/store');
});
});
await check('sorting: new, top, hot (deterministic formula at a fixed now), pinned first, pagination', async () => {
await check('sorting: new, top, hot (deterministic formula at a fixed now), pinned first, pagination', async () => {
- const space = forumStore.getSpace(t.db, 'showcase');
- const mk = (title, score, hoursAgo) => {
- const { thread } = forumStore.createThread(t.db, { space_id: space.id, title, author_subject: alex.subject_id, body_markdown: 'x' });
- t.db.prepare("UPDATE threads SET score = ?, created_at = datetime('2026-09-22 12:00:00', ?), last_activity_at = datetime('2026-09-22 12:00:00', ?) WHERE id = ?")
+ const space = await forumStore.getSpace(t.db, 'showcase');
+ const mk = async (title, score, hoursAgo) => {
+ const { thread } = await forumStore.createThread(t.db, { space_id: space.id, title, author_subject: alex.subject_id, body_markdown: 'x' });
+ await t.db.prepare("UPDATE threads SET score = ?, created_at = datetime('2026-09-22 12:00:00', ?), last_activity_at = datetime('2026-09-22 12:00:00', ?) WHERE id = ?")
.run(score, `-${hoursAgo} hours`, `-${hoursAgo} hours`, thread.id);
.run(score, `-${hoursAgo} hours`, `-${hoursAgo} hours`, thread.id);
return thread;
return thread;
};
};
- t.db.prepare('DELETE FROM threads WHERE space_id = ?').run(space.id);
- mk('old popular', 50, 48); // (51)/(50^1.5) ≈ 0.144
- mk('fresh quiet', 0, 1); // (1)/(3^1.5) ≈ 0.192
- mk('fresh liked', 5, 2); // (6)/(4^1.5) = 0.75
- mk('ancient', 3, 500);
+ await t.db.prepare('DELETE FROM threads WHERE space_id = ?').run(space.id);
+ await mk('old popular', 50, 48); // (51)/(50^1.5) ≈ 0.144
+ await mk('fresh quiet', 0, 1); // (1)/(3^1.5) ≈ 0.192
+ await mk('fresh liked', 5, 2); // (6)/(4^1.5) = 0.75
+ await mk('ancient', 3, 500);
const now = new Date('2026-09-22T12:00:00Z');
const now = new Date('2026-09-22T12:00:00Z');
const titles = async (sort, extra = {}) => (await forum.listThreads(asAlex, 'showcase', { sort, now, ...extra })).threads.map((x) => x.title);
const titles = async (sort, extra = {}) => (await forum.listThreads(asAlex, 'showcase', { sort, now, ...extra })).threads.map((x) => x.title);
assert.deepStrictEqual(await titles('hot'), ['fresh liked', 'fresh quiet', 'old popular', 'ancient']);
assert.deepStrictEqual(await titles('hot'), ['fresh liked', 'fresh quiet', 'old popular', 'ancient']);
assert.deepStrictEqual(await titles('hot'), await titles('hot'), 'same inputs, same order');
assert.deepStrictEqual(await titles('hot'), await titles('hot'), 'same inputs, same order');
assert.deepStrictEqual(await titles('new'), ['fresh quiet', 'fresh liked', 'old popular', 'ancient']);
assert.deepStrictEqual(await titles('new'), ['fresh quiet', 'fresh liked', 'old popular', 'ancient']);
assert.deepStrictEqual(await titles('top'), ['old popular', 'fresh liked', 'ancient', 'fresh quiet']);
assert.deepStrictEqual(await titles('top'), ['old popular', 'fresh liked', 'ancient', 'fresh quiet']);
- const ancient = t.db.prepare("SELECT id FROM threads WHERE title = 'ancient'").get().id;
- t.db.prepare('UPDATE threads SET pinned = 1 WHERE id = ?').run(ancient);
+ const ancient = (await t.db.prepare("SELECT id FROM threads WHERE title = 'ancient'").get()).id;
+ await t.db.prepare('UPDATE threads SET pinned = 1 WHERE id = ?').run(ancient);
assert.strictEqual((await titles('hot'))[0], 'ancient');
assert.strictEqual((await titles('hot'))[0], 'ancient');
const p1 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3 });
const p1 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3 });
const p2 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3, page: 2 });
const p2 = await forum.listThreads(asAlex, 'showcase', { sort: 'new', now, limit: 3, page: 2 });
assert.deepStrictEqual([p1.pages, p1.total, p1.threads.length, p2.threads.length], [2, 4, 3, 1]);
assert.deepStrictEqual([p1.pages, p1.total, p1.threads.length, p2.threads.length], [2, 4, 3, 1]);
- const hot = t.db.prepare('SELECT ov_hot(5, 2) AS h').get().h;
+ const hot = (await t.db.prepare('SELECT ov_hot(5, 2) AS h').get()).h;
assert.ok(Math.abs(hot - 6 / Math.pow(4, 1.5)) < 1e-12, 'ov_hot = (score + 1) / (hours + 2)^1.5');
assert.ok(Math.abs(hot - 6 / Math.pow(4, 1.5)) < 1e-12, 'ov_hot = (score + 1) / (hours + 2)^1.5');
});
});
@@ -180,7 +180,7 @@ const forumStore = require('../server/forum/store');
@@ -180,7 +180,7 @@ const forumStore = require('../server/forum/store');
});
});
await check('members and staff spaces: sign-in for members, invisible for non-staff', async () => {
await check('members and staff spaces: sign-in for members, invisible for non-staff', async () => {
- t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
+ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
assert.strictEqual((await call('/api/v1/spaces/insiders/threads')).status, 401);
assert.strictEqual((await call('/api/v1/spaces/insiders/threads')).status, 401);
assert.strictEqual((await call('/api/v1/spaces/insiders/threads', { cookie: samJwt })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/insiders/threads', { cookie: samJwt })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: samJwt })).status, 404);
assert.strictEqual((await call('/api/v1/spaces/mods/threads', { cookie: samJwt })).status, 404);
@@ -6,8 +6,9 @@ const mockNetwork = require('./mock-network');
@@ -6,8 +6,9 @@ const mockNetwork = require('./mock-network');
const mockMedia = require('./mock-media');
const mockMedia = require('./mock-media');
/**
/**
- * opts.authority 'live' (default) or 'community' — the latter runs the native paste API on a
- * fresh in-memory database, with Network and Media mocks behind it.
+ * opts.authority 'live' (default) or 'community' — the latter runs the native paste API, with Network and
+ * Media mocks behind it. Every boot gets a migrated database of its own (./db.js: PGlite, or the
+ * PostgreSQL containers under npm run test:pg, with Valkey for the limit counters).
* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
* opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
* opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).
* opts.appOpts passed through to createApp (commentLimits, forumLimits, relayOptions, …).
* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET
* opts.env environment set after the defaults, before the app loads (its OV_OAUTH_CLIENT_SECRET
@@ -31,16 +32,18 @@ async function boot(opts = {}) {
@@ -31,16 +32,18 @@ async function boot(opts = {}) {
process.env.TRUST_PROXY = '1';
process.env.TRUST_PROXY = '1';
process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;
process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;
process.env.PASTES_AUTHORITY = opts.authority || 'live';
process.env.PASTES_AUTHORITY = opts.authority || 'live';
- process.env.COMMUNITY_DB_PATH = ':memory:';
+ // test:pg: the Valkey container too, under a prefix of its own.
+ if (process.env.COMMUNITY_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL) {
+ process.env.VALKEY_URL = process.env.OV_TEST_VALKEY_URL;
+ process.env.VALKEY_PREFIX = `ov:community-test:${process.pid}:${Math.random().toString(36).slice(2, 10)}:`;
+ }
Object.assign(process.env, opts.env || {});
Object.assign(process.env, opts.env || {});
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
const appOpts = { ...(opts.appOpts || {}) };
const appOpts = { ...(opts.appOpts || {}) };
- if (opts.authority === 'community') {
- appOpts.db = require('../../server/db').openDb(':memory:');
- appOpts.pasteLimits = opts.pasteLimits;
- }
- const app = createApp(appOpts);
+ appOpts.db = appOpts.db || await require('./db').testDb();
+ if (opts.authority === 'community') appOpts.pasteLimits = opts.pasteLimits;
+ const app = await createApp(appOpts);
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -61,14 +64,18 @@ async function boot(opts = {}) {
@@ -61,14 +64,18 @@ async function boot(opts = {}) {
}
}
return {
return {
app, base, get, jar, live: liveSrv, network: netSrv, media: mediaSrv, db: appOpts.db || null,
app, base, get, jar, live: liveSrv, network: netSrv, media: mediaSrv, db: appOpts.db || null,
- close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); await mediaSrv.close(); },
+ close: async () => {
+ await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); await mediaSrv.close();
+ if (app.locals.valkey) await app.locals.valkey.close().catch(() => {});
+ await appOpts.db.close().catch(() => {});
+ },
};
};
}
}
let failures = 0;
let failures = 0;
async function check(name, fn) {
async function check(name, fn) {
try { await fn(); console.log(' ✓', name); }
try { await fn(); console.log(' ✓', name); }
- catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 4).join('\n ')); }
+ catch (e) { failures++; console.log(" ✗", name, "\n ", (e.stack || String(e)).split("\n").slice(0, process.env.DEBUG ? 40 : 4).join("\n ")); }
}
}
function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+/**
+ * A migrated database for one test (ADR-035), from openvibe-sdk/testing: PGlite by default; with COMMUNITY_TEST_STORE=pg
+ * (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of its own. The handle's close()
+ * also drops the test database.
+ */
+const { createTestDb } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+async function testDb({ store = process.env.COMMUNITY_TEST_STORE || 'pglite', max = 4 } = {}) {
+ const t = await createTestDb({ migrations: MIGRATIONS, store, service: 'community', max });
+ // db.close() runs the test database's own close (PGlite: closes it; the containers: also drops its roles and
+ // schema), once; that close calls the handle's original close, which is put back first.
+ const own = t.db.close;
+ let closing = null;
+ t.db.close = () => { if (!closing) { t.db.close = own; closing = t.close(); } return closing; };
+ return t.db;
+}
+
+module.exports = { testDb };
deleted file mode 100644
deleted file mode 100644
@@ -1,184 +0,0 @@
@@ -1,184 +0,0 @@
-'use strict';
-/**
- * Live's own VOD/clip comments → Community threads (server/comments/live-import.js and
- * scripts/import-live-comments.js): reconciliation (read = imported + held + excluded), authors
- * from Live's links and the Network's identity map (unmapped and disagreeing ones held), replies
- * nested one level, times and edits kept, the ledger making re-runs import nothing twice, held rows
- * imported once their author maps, dry run writing nothing, and --apply refusing without a backup.
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { spawnSync } = require('child_process');
-const Database = require('better-sqlite3');
-const { check, done } = require('./helpers/app');
-
-process.env.NODE_ENV = 'test';
-const { openDb } = require('../server/db');
-const { importLiveComments, formatReport } = require('../server/comments/live-import');
-const { createCommentService } = require('../server/comments/service');
-
-const A = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'; // live user 1, linked on Live
-const B = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB'; // live user 2, Network map only
-const C = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC'; // live user 4 per Live's link…
-const D = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD'; // …and per the Network: they disagree
-const E = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE'; // live user 3, linked later
-
-const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-comments-'));
-const livePath = path.join(dir, 'live.db');
-const live = new Database(livePath);
-live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);
-CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
-CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')), content_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL, parent_id INTEGER, message TEXT NOT NULL, is_deleted INTEGER DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
-live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?)").run(A);
-live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (4, 'network', '104', ?)").run(C);
-live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'twitch', 'x', NULL)").run();
-const add = (id, type, cid, uid, parent, message, extra = {}) => live.prepare(`INSERT INTO comments (id, content_type, content_id, user_id, parent_id, message, is_deleted, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, type, cid, uid, parent, message, extra.deleted ? 1 : 0, extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`, extra.updated || extra.created || `2026-08-0${Math.min(id, 9)} 10:00:00`);
-add(1, 'vod', 10, 1, null, 'first');
-add(2, 'vod', 10, 2, 1, 'a reply');
-add(3, 'vod', 10, 1, 2, 'reply to the reply');
-add(4, 'vod', 10, 3, null, 'by someone unmapped');
-add(5, 'vod', 10, 1, 4, 'under the held one');
-add(6, 'clip', 20, 4, null, 'two answers for this author');
-add(7, 'clip', 20, 1, null, 'deleted on live', { deleted: true });
-add(8, 'clip', 20, 2, 7, 'reply under a deleted comment');
-add(9, 'clip', 20, 1, null, 'edited once', { created: '2026-08-09 10:00:00', updated: '2026-08-09 11:30:00' });
-add(10, 'vod', 11, 1, 99, 'parent missing');
-add(11, 'vod', 10, 2, 9, 'parent on other content');
-add(12, 'vod', 12, 1, null, ' ');
-live.close();
-
-const networkMap = { 2: B, 4: D };
-const calls = [];
-const resolveLegacy = async (system, ids) => {
- calls.push({ system, ids });
- return new Map(ids.map((id) => [id, networkMap[id] ? { subject: { type: 'user', id: networkMap[id] } } : null]));
-};
-
-(async () => {
- const db = openDb(':memory:');
- const liveRo = new Database(livePath, { readonly: true });
- const count = (sql, ...a) => db.prepare(sql).get(...a).n;
- const threadOf = (type, id) => db.prepare("SELECT * FROM comment_threads WHERE ref_service = 'live' AND ref_type = ? AND ref_id = ?").get(type, String(id));
-
- await check('dry run (the default): the full report, nothing written', async () => {
- const r = await importLiveComments(db, liveRo, { resolveLegacy });
- assert.strictEqual(r.dry_run, true);
- assert.deepStrictEqual([r.read, r.imported, r.held, r.excluded], [12, 4, 3, 5]);
- assert.strictEqual(r.reconciled, true);
- assert.deepStrictEqual(r.excluded_by_reason, { deleted: 1, parent_deleted: 1, orphan: 2, empty: 1 });
- assert.deepStrictEqual(r.held_rows.map((h) => [h.live_id, h.reason]), [[4, 'unmapped_author'], [6, 'ambiguous_author'], [5, 'parent_held']]);
- assert.deepStrictEqual(calls[0], { system: 'live', ids: ['1', '2', '3', '4'] }, 'only authors of importable rows are looked up');
- for (const table of ['comments', 'comment_threads', 'legacy_id_map', 'import_hold', 'migration_runs']) {
- assert.strictEqual(count(`SELECT COUNT(*) AS n FROM ${table}`), 0, `${table} untouched`);
- }
- const text = formatReport(r);
- assert.match(text, /DRY RUN/);
- assert.match(text, /read 12 = imported 4 \+ held 3 \+ excluded 5 → OK/);
- assert.match(text, /live comment 4 {2}unmapped_author {2}on vod\/10 {2}live user 3/);
- });
-
- await check('apply: threads per VOD/clip, authors as subjects, one level of nesting, times and edits kept, counters, holds, a run record', async () => {
- const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false, source: 'live.db' });
- assert.deepStrictEqual([r.read, r.imported_new, r.imported_before, r.held, r.excluded], [12, 4, 0, 3, 5]);
- const vod = threadOf('vod', 10), clip = threadOf('clip', 20);
- assert.ok(vod && clip && !threadOf('vod', 11) && !threadOf('vod', 12), 'threads only where something was imported');
- assert.strictEqual(r.threads.length, 2);
- assert.strictEqual(vod.comment_count, 3);
- assert.strictEqual(clip.comment_count, 1);
- const idOf = (liveId) => Number(db.prepare("SELECT target_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND source_id = ?").get(String(liveId)).target_id);
- const row = (liveId) => db.prepare('SELECT * FROM comments WHERE id = ?').get(idOf(liveId));
- assert.deepStrictEqual([row(1).author_subject, row(1).parent_id, row(1).message, row(1).created_at, row(1).edited_at], [A, null, 'first', '2026-08-01 10:00:00', null]);
- assert.deepStrictEqual([row(2).author_subject, row(2).parent_id], [B, idOf(1)], 'a Network-only author');
- assert.strictEqual(row(3).parent_id, idOf(1), 'a reply to a reply joins the top-level comment');
- assert.strictEqual(row(1).reply_count, 2);
- assert.deepStrictEqual([row(9).created_at, row(9).edited_at], ['2026-08-09 10:00:00', '2026-08-09 11:30:00']);
- assert.strictEqual(row(9).origin, 'user');
- const holds = db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment' ORDER BY source_id").all();
- assert.deepStrictEqual(holds.map((h) => [h.source_id, h.reason]), [['4', 'unmapped_author'], ['5', 'parent_held'], ['6', 'ambiguous_author']]);
- assert.strictEqual(count("SELECT COUNT(*) AS n FROM migration_runs WHERE source = 'live-comments:live.db'"), 1);
-
- // What Live's adapter reads back: newest first, replies nested, edits marked.
- const svc = createCommentService({ db });
- const page = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, vod.id, { sort: 'new' });
- assert.deepStrictEqual(page.comments.map((c) => c.message), ['first']);
- assert.deepStrictEqual(page.comments[0].replies.map((c) => c.message), ['a reply', 'reply to the reply']);
- const clipPage = await svc.get({ kind: 'service', service: 'svc:live', claims: { cap: ['community.comment.write'] }, subject: null }, clip.id, {});
- assert.strictEqual(clipPage.comments[0].edited_at, '2026-08-09T11:30:00.000Z');
- });
-
- await check('the ledger: a second run imports nothing twice', async () => {
- const before = count('SELECT COUNT(*) AS n FROM comments');
- const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });
- assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded, r.reconciled], [0, 4, 3, 5, true]);
- assert.strictEqual(count('SELECT COUNT(*) AS n FROM comments'), before);
- assert.strictEqual(threadOf('vod', 10).comment_count, 3);
- });
-
- await check('held rows import once their author maps; their holds are cleared; replies follow', async () => {
- const rw = new Database(livePath);
- rw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (3, 'network', '103', ?)").run(E);
- rw.close();
- const r = await importLiveComments(db, liveRo, { resolveLegacy, dryRun: false });
- assert.deepStrictEqual([r.imported_new, r.imported_before, r.held, r.excluded], [2, 4, 1, 5]);
- assert.deepStrictEqual(db.prepare("SELECT source_id, reason FROM import_hold WHERE source_type = 'live_comment'").all(), [{ source_id: '6', reason: 'ambiguous_author' }]);
- assert.strictEqual(threadOf('vod', 10).comment_count, 5);
- const liveIdOf = (communityId) => db.prepare("SELECT source_id FROM legacy_id_map WHERE source_system = 'live' AND source_type = 'comment' AND target_id = ?").get(String(communityId)).source_id;
- const reply = db.prepare("SELECT * FROM comments WHERE message = 'under the held one'").get();
- assert.strictEqual(liveIdOf(reply.parent_id), '4');
- });
-
- await check('Live links only (no Network call): the Network-only author is held', async () => {
- const fresh = openDb(':memory:');
- const r = await importLiveComments(fresh, liveRo, { resolveLegacy: null });
- assert.strictEqual(r.reconciled, true);
- assert.ok(r.held_rows.some((h) => h.live_id === 2 && h.reason === 'unmapped_author'));
- assert.ok(!r.held_rows.some((h) => h.live_id === 3), 'a reply to it joins the top-level comment, which is not held');
- assert.ok(!r.held_rows.some((h) => h.live_id === 6), 'with one source there is nothing to disagree');
- fresh.close();
- });
-
- await check('the script: dry run by default; --apply refused without --backup; the backup is taken and checked; re-runs are no-ops', async () => {
- const communityPath = path.join(dir, 'community.db');
- openDb(communityPath).close();
- const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'import-live-comments.js'), ...args], {
- cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, COMMUNITY_DB_PATH: communityPath, OV_OAUTH_CLIENT_SECRET: '' },
- });
- const base = ['--live-db', livePath, '--community-db', communityPath, '--live-links-only'];
- const dry = run(...base);
- assert.strictEqual(dry.status, 0, dry.stderr);
- assert.match(dry.stdout, /DRY RUN/);
- assert.match(dry.stdout, /Nothing was written/);
- const peek = () => { const d = new Database(communityPath, { readonly: true }); const n = d.prepare('SELECT COUNT(*) AS n FROM comments').get().n; d.close(); return n; };
- assert.strictEqual(peek(), 0);
- const noBackup = run(...base, '--apply');
- assert.strictEqual(noBackup.status, 2);
- assert.match(noBackup.stderr, /--apply needs --backup/);
- assert.strictEqual(peek(), 0);
- const backup = path.join(dir, 'community-before.db');
- const applied = run(...base, '--apply', '--backup', backup);
- assert.strictEqual(applied.status, 0, applied.stderr);
- assert.match(applied.stdout, /Backup: .*community-before\.db \(integrity ok; 0 comments in 0 threads\)/);
- assert.match(applied.stdout, /APPLIED/);
- assert.ok(fs.existsSync(backup));
- const imported = peek();
- assert.ok(imported > 0);
- const again = run(...base, '--apply', '--backup', backup);
- assert.strictEqual(again.status, 2, 'an existing backup file is never overwritten');
- const again2 = run(...base, '--apply', '--backup', path.join(dir, 'community-before-2.db'));
- assert.strictEqual(again2.status, 0, again2.stderr);
- assert.match(again2.stdout, /new 0, already imported/);
- assert.strictEqual(peek(), imported);
- const missing = run('--live-db', path.join(dir, 'nope.db'), '--community-db', communityPath);
- assert.strictEqual(missing.status, 2);
- });
-
- liveRo.close();
- db.close();
- fs.rmSync(dir, { recursive: true, force: true });
- done();
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,275 +0,0 @@
@@ -1,275 +0,0 @@
-'use strict';
-/**
- * scripts/import-pastes.js + server/pastes/importer.js: bundle verification, owner mapping across
- * the id-space cutoff (ambiguous / unmapped → holds), AI pastes, fork and reply remapping, likes,
- * idempotent re-runs, newer bundles, dry runs, and the CLI's exit codes.
- */
-const assert = require('assert');
-const crypto = require('crypto');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { spawnSync } = require('child_process');
-const { openDb } = require('../server/db');
-const { importBundle, ImportError } = require('../server/pastes/importer');
-const { createNetworkIdentity } = require('../server/identity/network');
-const mockNetwork = require('./helpers/mock-network');
-const { check, done } = require('./helpers/app');
-
-const PRE = '2026-08-18 10:00:00'; // between the OpenVibe launch (2026-08-17T21:10:29Z) and the id-space fix (2026-08-20T03:00:26Z)
-const MIGRATED = '2026-08-01 10:00:00'; // before the launch: rows migrated from HoboStreamer, Live ids
-const POST = '2026-09-01 10:00:00';
-
-function seal(bundle) {
- const { pastes, comments, likes } = bundle;
- return {
- format: 'openvibe.media.pastes-export', version: 1, app: 'live', generated_at: '2026-09-22T12:00:00Z',
- since_id: 0, max_id: Math.max(0, ...pastes.map((p) => p.id)),
- counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },
- sha256: crypto.createHash('sha256').update(JSON.stringify({ pastes, comments, likes })).digest('hex'),
- pastes, comments, likes,
- };
-}
-
-const paste = (o) => ({
- id: 1, slug: 'amber-fox-11', user_id: null, type: 'paste', title: 'T', content: 'c', language: 'text', visibility: 'public',
- stream_id: null, screenshot_url: null, metadata: null, burn_after_read: 0, forked_from: null, pinned: 0, views: 0, unique_views: 0,
- copies: 0, likes: 0, is_nsfw: 0, ai_summary: null, ai_tags: null, ai_analyzed_at: null, created_at: POST, updated_at: POST, ...o,
-});
-
-(async () => {
- const net = await mockNetwork.start();
- // A: network 7 = live 107. B: network 9 = live 109. C: live 9 (so "9" is ambiguous before the cutoff).
- const A = net.addUser({ network_user_id: 7, username: 'alex' });
- const B = net.addUser({ network_user_id: 9, username: 'sam' });
- const C = net.addUser({ network_user_id: 30, username: 'cleo' });
- net.directory.legacy.live = { 107: A.subject_id, 109: B.subject_id, 9: C.subject_id };
- const config = { networkInternalUrl: net.url, networkUrl: net.url, oauth: { clientId: 'community', clientSecret: 'shh' } };
-
- const db = openDb(':memory:');
- const identity = createNetworkIdentity({ config, db });
- const run = (bundle, opts = {}) => importBundle(db, bundle, { resolveLegacy: identity.resolveLegacy, source: 'test', ...opts });
- const count = (table) => db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get().c;
- const bySlug = (slug) => db.prepare('SELECT * FROM pastes WHERE slug = ?').get(slug);
- const holds = () => db.prepare('SELECT source_type, source_id, reason FROM import_hold ORDER BY source_type, source_id, reason').all();
-
- const pastes = [
- paste({ id: 6, slug: 'fork-of-one-66', user_id: 107, title: 'Fork of One', forked_from: 1 }), // fork listed before its original
- paste({ id: 1, slug: 'amber-fox-11', user_id: 107, title: 'One', content: 'hello', language: 'javascript', views: 50, unique_views: 40, copies: 3, likes: 2, visibility: 'public', is_nsfw: 1, ai_summary: 'greets', ai_tags: ['hi'], ai_analyzed_at: '2026-09-02 00:00:00', created_at: POST, updated_at: '2026-09-03 00:00:00' }),
- paste({ id: 2, slug: 'blue-lake-22', user_id: 7, created_at: PRE, updated_at: PRE, visibility: 'unlisted' }), // pre-cutoff, only the network mapping exists → A
- paste({ id: 3, slug: 'cold-ridge-33', user_id: 9, created_at: PRE, updated_at: PRE }), // pre-cutoff, live 9 = C, network 9 = B → ambiguous
- paste({ id: 4, slug: 'dark-owl-44', user_id: 555 }), // post-cutoff, unmapped
- paste({ id: 5, slug: 'wild-moment-5555', user_id: 109, type: 'screenshot', content: '', screenshot_url: 'https://openvibe.media/p/wild-moment-5555/screenshot', metadata: { ai_moment: true, stream_id: 77 }, ai_summary: 'hype' }),
- paste({ id: 7, slug: 'epic-frame-7777', user_id: 109, type: 'screenshot', screenshot_url: 'https://openvibe.media/p/epic-frame-7777/screenshot', stream_id: 12, metadata: { original_name: 'ai-moment-vod31-340.jpg', mime_type: 'image/jpeg' } }),
- paste({ id: 8, slug: 'gold-gem-88', user_id: 777, created_at: PRE, updated_at: PRE, visibility: 'private' }), // pre-cutoff, no mapping at all
- paste({ id: 9, slug: 'gray-dove-99', user_id: null, title: 'anon' }),
- ];
- const comments = [
- { id: 11, paste_id: 1, user_id: 107, parent_id: null, anon_name: null, message: 'nice', is_deleted: 0, created_at: POST, updated_at: POST },
- { id: 12, paste_id: 1, user_id: null, parent_id: 11, anon_name: 'Visitor', message: 'agreed', is_deleted: 0, created_at: POST, updated_at: POST },
- { id: 13, paste_id: 1, user_id: 555, parent_id: null, anon_name: null, message: 'who am i', is_deleted: 0, created_at: POST, updated_at: POST },
- { id: 14, paste_id: 99, user_id: 107, parent_id: null, anon_name: null, message: 'orphan', is_deleted: 0, created_at: POST, updated_at: POST },
- ];
- const likes = [
- { paste_id: 1, user_id: 107, created_at: POST },
- { paste_id: 1, user_id: 555, created_at: POST },
- { paste_id: 1, user_id: 9, created_at: PRE },
- ];
- const bundle = seal({ pastes, comments, likes });
-
- await check('refuses a bundle whose format, version, counts or sha256 do not check out', async () => {
- for (const [mutate, re] of [
- [(b) => { b.sha256 = '0'.repeat(64); }, /sha256 mismatch/],
- [(b) => { b.counts.pastes += 1; }, /count mismatch for pastes/],
- [(b) => { b.format = 'something-else'; }, /unexpected format/],
- [(b) => { b.version = 2; }, /unsupported version/],
- [(b) => { b.pastes[0].title = 'tampered'; }, /sha256 mismatch/],
- ]) {
- const b = JSON.parse(JSON.stringify(bundle));
- mutate(b);
- await assert.rejects(run(b), (err) => err instanceof ImportError && re.test(err.message));
- }
- assert.strictEqual(count('pastes'), 0);
- });
-
- await check('--dry-run reports everything and leaves the database untouched', async () => {
- const r = await run(bundle, { dryRun: true });
- assert.strictEqual(r.dry_run, true);
- assert.strictEqual(r.pastes.inserted, 9);
- for (const t of ['pastes', 'paste_comments', 'paste_likes', 'import_hold', 'legacy_id_map', 'migration_runs']) assert.strictEqual(count(t), 0, t);
- });
-
- let first;
- await check('import: counters, timestamps, visibility, NSFW and AI fields kept; owners mapped across the cutoff', async () => {
- first = await run(bundle);
- assert.deepStrictEqual(first.pastes, { bundle: 9, inserted: 9, updated: 0, unchanged: 0, held: 3, skipped: 0, ai: 2, forks_remapped: 1, forks_unresolved: 0 });
- const one = bySlug('amber-fox-11');
- assert.strictEqual(one.owner_subject, A.subject_id);
- assert.strictEqual(one.legacy_media_id, 1);
- assert.strictEqual(one.legacy_user_id, 107);
- assert.strictEqual(one.views, 50);
- assert.strictEqual(one.unique_views, 40);
- assert.strictEqual(one.copies, 3);
- assert.strictEqual(one.likes, 2);
- assert.strictEqual(one.is_nsfw, 1);
- assert.strictEqual(one.ai_tags, '["hi"]');
- assert.strictEqual(one.created_at, POST);
- assert.strictEqual(one.updated_at, '2026-09-03 00:00:00');
- assert.strictEqual(one.origin, 'user');
- assert.strictEqual(bySlug('blue-lake-22').owner_subject, A.subject_id, 'pre-cutoff network id');
- assert.strictEqual(bySlug('blue-lake-22').visibility, 'unlisted');
- assert.strictEqual(bySlug('cold-ridge-33').owner_subject, null, 'ambiguous');
- assert.strictEqual(bySlug('cold-ridge-33').legacy_user_id, 9);
- assert.strictEqual(bySlug('dark-owl-44').owner_subject, null, 'unmapped');
- assert.strictEqual(bySlug('gold-gem-88').owner_subject, null);
- assert.strictEqual(bySlug('gray-dove-99').owner_subject, null, 'anonymous is not a hold');
- assert.strictEqual(bySlug('fork-of-one-66').forked_from, one.id, 'forked_from remapped to the Community id');
- });
-
- await check('AI moment pastes: origin ai, no owner, stream in stream_ref, screenshot link kept, no hold', async () => {
- const ai = bySlug('wild-moment-5555');
- assert.strictEqual(ai.origin, 'ai');
- assert.strictEqual(ai.owner_subject, null);
- assert.strictEqual(ai.legacy_user_id, 109);
- assert.deepStrictEqual(JSON.parse(ai.stream_ref), { service: 'live', type: 'stream', id: '77' });
- assert.strictEqual(ai.screenshot_url, 'https://openvibe.media/p/wild-moment-5555/screenshot');
- assert.strictEqual(ai.media_ref, 'legacy:live:paste:5');
- const byName = bySlug('epic-frame-7777');
- assert.strictEqual(byName.origin, 'ai', 'recognised by the AI frame file name Media kept');
- assert.deepStrictEqual(JSON.parse(byName.stream_ref), { service: 'live', type: 'stream', id: '12' });
- assert.ok(!holds().some((h) => h.source_id === '5' || h.source_id === '7'));
- });
-
- await check('holds: ambiguous / unmapped owners, unmappable likes and orphan comments', async () => {
- assert.deepStrictEqual(holds(), [
- { source_type: 'comment', source_id: '13', reason: 'owner_unmapped' },
- { source_type: 'comment', source_id: '14', reason: 'paste_missing' },
- { source_type: 'like', source_id: '1:555', reason: 'owner_unmapped' },
- { source_type: 'like', source_id: '1:9', reason: 'ambiguous_owner' },
- { source_type: 'paste', source_id: '3', reason: 'ambiguous_owner' },
- { source_type: 'paste', source_id: '4', reason: 'owner_unmapped' },
- { source_type: 'paste', source_id: '8', reason: 'owner_unmapped' },
- ]);
- const detail = JSON.parse(db.prepare("SELECT detail FROM import_hold WHERE source_type = 'paste' AND source_id = '3'").get().detail);
- assert.strictEqual(detail.live_subject, C.subject_id);
- assert.strictEqual(detail.network_subject, B.subject_id);
- assert.deepStrictEqual(first.holds, { ambiguous_owner: 2, owner_unmapped: 4, paste_missing: 1 });
- });
-
- await check('comments and likes: authors mapped, reply parents remapped, only mappable likes imported', async () => {
- const cs = db.prepare('SELECT * FROM paste_comments ORDER BY legacy_media_id').all();
- assert.strictEqual(cs.length, 3);
- const [c11, c12, c13] = cs;
- assert.strictEqual(c11.author_subject, A.subject_id);
- assert.strictEqual(c12.anon_name, 'Visitor');
- assert.strictEqual(c12.parent_id, c11.id);
- assert.strictEqual(c13.author_subject, null);
- assert.deepStrictEqual(first.comments, { bundle: 4, inserted: 3, updated: 0, unchanged: 0, held: 1, skipped: 1 });
- const ls = db.prepare('SELECT subject_id FROM paste_likes').all();
- assert.deepStrictEqual(ls, [{ subject_id: A.subject_id }]);
- assert.deepStrictEqual(first.likes, { bundle: 3, inserted: 1, updated: 0, unchanged: 0, held: 2, skipped: 0 });
- });
-
- await check('mappings and the run are recorded', async () => {
- const m = (sys, type, id) => db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?').get(sys, type, id);
- assert.deepStrictEqual(m('media', 'paste', '1'), { target_type: 'paste', target_id: String(bySlug('amber-fox-11').id) });
- assert.deepStrictEqual(m('live', 'user', '107'), { target_type: 'subject', target_id: A.subject_id });
- assert.deepStrictEqual(m('network', 'user', '7'), { target_type: 'subject', target_id: A.subject_id });
- assert.ok(m('media', 'comment', '12'));
- const runs = db.prepare('SELECT * FROM migration_runs').all();
- assert.strictEqual(runs.length, 1);
- assert.strictEqual(JSON.parse(runs[0].report).pastes.inserted, 9);
- assert.ok(runs[0].finished_at);
- });
-
- await check('re-running the same bundle changes nothing', async () => {
- const before = { pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') };
- const again = await run(bundle);
- assert.deepStrictEqual([again.pastes.inserted, again.pastes.updated, again.pastes.unchanged], [0, 0, 9]);
- assert.deepStrictEqual([again.comments.inserted, again.comments.updated, again.comments.unchanged], [0, 0, 3]);
- assert.deepStrictEqual([again.likes.inserted, again.likes.unchanged, again.likes.held], [0, 1, 2]);
- assert.deepStrictEqual({ pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') }, before);
- assert.strictEqual(count('migration_runs'), 2);
- });
-
- await check('a newer bundle adds and updates; Community-side edits, counts and deletes are not undone; late mappings clear holds', async () => {
- // Community-side activity since the first import.
- db.prepare("UPDATE pastes SET views = 80 WHERE slug = 'amber-fox-11'").run();
- const store = require('../server/pastes/store');
- store.updatePaste(db, bySlug('blue-lake-22').id, { content: 'edited in Community' }, A.subject_id);
- store.softDelete(db, bySlug('gray-dove-99').id);
- // Someone registered live user 555 with the Network in the meantime.
- const D = net.addUser({ network_user_id: 40, username: 'dana' });
- net.directory.legacy.live[555] = D.subject_id;
-
- const newer = seal({
- pastes: pastes.map((p) => (p.id === 1 ? { ...p, title: 'One (renamed on Live)', views: 60, updated_at: '2026-09-10 00:00:00' }
- : p.id === 2 ? { ...p, content: 'changed on Live' } : p))
- .concat([paste({ id: 10, slug: 'new-wave-10', user_id: 109, created_at: '2026-09-20 00:00:00', updated_at: '2026-09-20 00:00:00' })]),
- comments: comments.concat([{ id: 15, paste_id: 10, user_id: 109, parent_id: null, anon_name: null, message: 'fresh', is_deleted: 0, created_at: POST, updated_at: POST }]),
- likes,
- });
- const r = await run(newer);
- assert.strictEqual(r.pastes.inserted, 1);
- assert.strictEqual(r.comments.inserted, 1);
- const one = bySlug('amber-fox-11');
- assert.strictEqual(one.title, 'One (renamed on Live)');
- assert.strictEqual(one.views, 80, 'counters never go down');
- assert.strictEqual(bySlug('blue-lake-22').content, 'edited in Community', 'local edits win');
- assert.ok(bySlug('gray-dove-99').deleted_at, 'deleted stays deleted');
- assert.strictEqual(bySlug('new-wave-10').owner_subject, B.subject_id);
- assert.strictEqual(bySlug('dark-owl-44').owner_subject, D.subject_id, 'late mapping fills the owner');
- assert.ok(!holds().some((h) => h.source_type === 'paste' && h.source_id === '4'), 'and clears its hold');
- assert.ok(!holds().some((h) => h.source_type === 'like' && h.source_id === '1:555'));
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS c FROM paste_likes').get().c, 2);
- assert.strictEqual(db.prepare("SELECT author_subject FROM paste_comments WHERE legacy_media_id = 13").get().author_subject, D.subject_id);
- assert.strictEqual(r.pastes.updated, 2, 'the renamed paste and the newly owned one');
- });
-
- await check('rows from before the OpenVibe launch map by Live id even when the number is also a Network id', async () => {
- const mdb = openDb(':memory:');
- const r = await importBundle(mdb, seal({ pastes: [paste({ id: 70, slug: 'old-hobo-70', user_id: 9, created_at: MIGRATED, updated_at: MIGRATED })], comments: [], likes: [] }), { resolveLegacy: identity.resolveLegacy, source: 'test' });
- const row = mdb.prepare('SELECT owner_subject FROM pastes WHERE slug = ?').get('old-hobo-70');
- assert.strictEqual(row.owner_subject, C.subject_id, 'live 9 = cleo, not network 9 = sam');
- assert.strictEqual(r.pastes.held, 0);
- const r2 = await importBundle(openDb(':memory:'), seal({ pastes: [paste({ id: 70, slug: 'old-hobo-70', user_id: 9, created_at: MIGRATED, updated_at: MIGRATED })], comments: [], likes: [] }), { resolveLegacy: identity.resolveLegacy, source: 'test', ambiguousFrom: null });
- assert.strictEqual(r2.pastes.held, 1, 'ambiguousFrom: null restores the whole-history window');
- mdb.close();
- });
-
- await check('CLI: exit 2 on a refused bundle, dry run prints the report and writes nothing', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-import-'));
- const dbPath = path.join(dir, 'c.db');
- const good = path.join(dir, 'good.json');
- const bad = path.join(dir, 'bad.json');
- fs.writeFileSync(good, JSON.stringify(bundle));
- fs.writeFileSync(bad, JSON.stringify({ ...bundle, sha256: 'f'.repeat(64) }));
- const env = { ...process.env, COMMUNITY_DB_PATH: dbPath, OV_NETWORK_INTERNAL_URL: net.url, OV_NETWORK_URL: net.url, OV_OAUTH_CLIENT_ID: 'community', OV_OAUTH_CLIENT_SECRET: 'shh' };
- const script = path.join(__dirname, '..', 'scripts', 'import-pastes.js');
- const refused = spawnSync(process.execPath, [script, bad], { env, encoding: 'utf8' });
- assert.strictEqual(refused.status, 2, refused.stderr);
- assert.match(refused.stderr, /Refused: sha256 mismatch/);
- // Run the child asynchronously: the mock Network lives in this process's event loop.
- const { spawn } = require('child_process');
- const out = await new Promise((resolve) => {
- const child = spawn(process.execPath, [script, good, '--dry-run', '--id-fix-cutoff', '2026-08-20T03:00:26Z'], { env });
- let stdout = '', stderr = '';
- child.stdout.on('data', (c) => { stdout += c; });
- child.stderr.on('data', (c) => { stderr += c; });
- child.on('close', (code) => resolve({ code, stdout, stderr }));
- });
- assert.strictEqual(out.code, 0, out.stderr);
- assert.match(out.stdout, /DRY RUN — rolled back/);
- // Live user 555 is mapped by now (previous check), so two pastes are held, not three.
- assert.match(out.stdout, /pastes\s+9\s+9\s+0\s+0\s+2\s+0/, out.stdout);
- const check2 = openDb(dbPath);
- assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM pastes').get().c, 0);
- assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM subject_projection').get().c, 0, 'not even the display cache');
- check2.close();
- fs.rmSync(dir, { recursive: true, force: true });
- });
-
- db.close();
- await net.close();
- done();
-})();
@@ -181,7 +181,7 @@ function startVip() {
@@ -181,7 +181,7 @@ function startVip() {
await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {
await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {
const pulse = (await call('/api/v1/pulse')).text;
const pulse = (await call('/api/v1/pulse')).text;
assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id)).c, 0);
+ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);
const sitemap = (await call('/sitemap.xml')).text;
const sitemap = (await call('/sitemap.xml')).text;
assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');
assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');
const feed = (await call('/s/feed.xml')).text;
const feed = (await call('/s/feed.xml')).text;
@@ -195,7 +195,7 @@ function startVip() {
@@ -195,7 +195,7 @@ function startVip() {
await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });
await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });
assert.ok(!(await call('/api/v1/pulse')).text.includes('Soon private'));
assert.ok(!(await call('/api/v1/pulse')).text.includes('Soon private'));
// Pulse's read-time check alone hides an item nothing removed.
// Pulse's read-time check alone hides an item nothing removed.
- t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));
+ await t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));
assert.ok(!(await call('/api/v1/pulse')).text.includes('stale gated item'));
assert.ok(!(await call('/api/v1/pulse')).text.includes('stale gated item'));
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,50 +0,0 @@
@@ -1,50 +0,0 @@
-'use strict';
-/**
- * scripts/migrate-screenshot-refs.js (C-24): a dry run changes nothing; --apply needs --backup and takes it
- * first; imported pastes (legacy:live:paste:<Live id>) are matched by slug, v1 uploads by their reference;
- * deleted objects and pastes with no object keep their legacy reference; running it again changes nothing.
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { execFileSync } = require('child_process');
-const { openDb } = require('../server/db');
-
-const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-c24-'));
-const dbPath = path.join(dir, 'community.db');
-const db = openDb(dbPath);
-const ins = db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url, media_ref) VALUES (?, 'screenshot', 'Shot', ?, ?)");
-ins.run('a1', 'https://openvibe.media/p/a1/screenshot', 'legacy:live:paste:2');
-ins.run('a2', 'https://openvibe.media/p/a2/screenshot', 'legacy:live:paste:4');
-ins.run('a3', 'https://openvibe.media/p/a3/screenshot', 'legacy:live:paste:9');
-ins.run('k1', 'https://openvibe.media/f/k1', 'legacy:community:file:k1');
-db.prepare("INSERT INTO pastes (slug, type, title, content) VALUES ('t1', 'paste', 'Text', 'x')").run();
-db.close();
-const M1 = 'med_01KKDDF5T0R3JNZSTEBW0X4AH5', M2 = 'med_01KKDDZWZ0SG9ZQ3ABAKDDSGWG', M3 = 'med_01KKDMS07GK1BS5KWRXWC6NAV8';
-const map = path.join(dir, 'refs.json');
-fs.writeFileSync(map, JSON.stringify({ objects: [
- { legacy_ref: 'legacy:live:paste:a1', id: M1, lifecycle_status: 'ready' },
- { legacy_ref: 'legacy:live:paste:a2', id: M2, lifecycle_status: 'deleted' },
- { legacy_ref: 'legacy:community:file:k1', id: M3, lifecycle_status: 'ready' },
-] }));
-const SCRIPT = path.join(__dirname, '..', 'scripts', 'migrate-screenshot-refs.js');
-const run = (...extra) => {
- try { return { code: 0, out: JSON.parse(execFileSync(process.execPath, [SCRIPT, '--db', dbPath, '--map', map, ...extra], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })) }; } catch (err) { return { code: err.status, out: null }; }
-};
-const refs = () => { const d = openDb(dbPath); const r = Object.fromEntries(d.prepare('SELECT slug, media_ref FROM pastes ORDER BY id').all().map((x) => [x.slug, x.media_ref])); d.close(); return r; };
-
-let r = run();
-assert.strictEqual(r.code, 0);
-assert.deepStrictEqual([r.out.candidates, r.out.would_migrate, r.out.no_object, r.out.not_ready, r.out.migrated], [4, 2, 1, 1, 0]);
-assert.strictEqual(refs().a1, 'legacy:live:paste:2', 'a dry run changes nothing');
-assert.strictEqual(run('--apply').code, 2, '--apply without --backup is refused');
-const backup = path.join(dir, 'before.db');
-r = run('--apply', '--backup', backup);
-assert.strictEqual(r.code, 0); assert.strictEqual(r.out.migrated, 2);
-assert.ok(fs.existsSync(backup) && fs.statSync(backup).size > 0, 'the backup was taken');
-assert.deepStrictEqual(refs(), { a1: M1, a2: 'legacy:live:paste:4', a3: 'legacy:live:paste:9', k1: M3, t1: null });
-r = run('--apply', '--backup', path.join(dir, 'again.db'));
-assert.deepStrictEqual([r.out.candidates, r.out.migrated], [2, 0], 'idempotent');
-fs.rmSync(dir, { recursive: true, force: true });
-console.log('migrate-screenshot-refs: all checks passed');
@@ -77,7 +77,24 @@ async function check(name, fn) {
@@ -77,7 +77,24 @@ async function check(name, fn) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
let server = null;
let server = null;
+ // This release on PostgreSQL (ADR-035) and N-1 on SQLite: they never share a database (the switch stops the
+ // service, imports, then starts this release), so N-1's SQL has no schema of this release to run on. The client
+ // half still runs, against a fresh migrated database.
+ const pg = !!require('../server/db').initDb;
+ const sqlSkip = pg && worker.engine !== 'postgresql' ? 'N-1 SQL: skipped (N-1 runs on SQLite and this release on PostgreSQL: no shared database, so no N-1 statement runs on this schema; scripts/migrate-to-postgres.js moves the data and was rehearsed)' : null;
+ // Both on PostgreSQL: N keeps every migration N-1 ran, byte for byte, and only adds (expand first, ADR-028).
+ if (pg && worker.engine === 'postgresql') {
+ await check(`N-1's ${worker.migrations.length} migration file(s) are all here, unchanged`, () => {
+ const dir = path.join(ROOT, 'migrations');
+ const hash = (f) => require('crypto').createHash('sha256').update(fs.readFileSync(path.join(dir, f))).digest('hex');
+ const bad = worker.migrations.filter((m) => !fs.existsSync(path.join(dir, m.name)) || hash(m.name) !== m.sha256).map((m) => m.name);
+ assert.deepStrictEqual(bad, [], 'a migration N-1 ran was removed or edited: write a new one instead');
+ });
+ }
try {
try {
+ if (pg) {
+ server = await svc.boot({ dir: ROOT, dbPath, dataDir });
+ } else {
console.log(`n-1: this release on a database N-1 created`);
console.log(`n-1: this release on a database N-1 created`);
await check('N-1\'s schema, ledger and first-use tables load, and this release migrates and seeds it', () => {
await check('N-1\'s schema, ledger and first-use tables load, and this release migrates and seeds it', () => {
const d = new Database(dbPath);
const d = new Database(dbPath);
@@ -96,6 +113,7 @@ async function check(name, fn) {
@@ -96,6 +113,7 @@ async function check(name, fn) {
svc.seed({ dir: ROOT, dbPath, dataDir });
svc.seed({ dir: ROOT, dbPath, dataDir });
});
});
server = await svc.boot({ dir: ROOT, dbPath, dataDir });
server = await svc.boot({ dir: ROOT, dbPath, dataDir });
+ }
const manifest = await (await fetch(`${server.url}/release.json`)).json();
const manifest = await (await fetch(`${server.url}/release.json`)).json();
console.log('n-1: the N-1 client against this server');
console.log('n-1: the N-1 client against this server');
@@ -124,6 +142,8 @@ async function check(name, fn) {
@@ -124,6 +142,8 @@ async function check(name, fn) {
await server.close();
await server.close();
server = null;
server = null;
+ if (sqlSkip) console.log(sqlSkip);
+ else if (!pg) {
console.log('n-1: N-1\'s SQL on the schema this release migrated');
console.log('n-1: N-1\'s SQL on the schema this release migrated');
await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, () => {
await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, () => {
const d = new Database(dbPath, { readonly: true });
const d = new Database(dbPath, { readonly: true });
@@ -131,6 +151,7 @@ async function check(name, fn) {
@@ -131,6 +151,7 @@ async function check(name, fn) {
d.close();
d.close();
assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');
assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');
});
});
+ }
} finally {
} finally {
if (server) { console.log(server.log().slice(-1500)); await server.close(); }
if (server) { console.log(server.log().slice(-1500)); await server.close(); }
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -35,7 +35,13 @@ const path = require('path');
@@ -35,7 +35,13 @@ const path = require('path');
COMMUNITY_DB_PATH: process.env.N1_DB,
COMMUNITY_DB_PATH: process.env.N1_DB,
});
});
const { createApp } = require(path.join(dir, 'server', 'app'));
const { createApp } = require(path.join(dir, 'server', 'app'));
- const app = createApp({
+ // A release on PostgreSQL (ADR-035; server/db.js initDb) gets a migrated test database of its own; a SQLite
+ // release opens COMMUNITY_DB_PATH itself.
+ const dbMod = require(path.join(dir, 'server', 'db'));
+ const pgDb = dbMod.initDb ? await require(path.join(dir, 'test', 'helpers', 'db')).testDb() : null;
+ if (pgDb) dbMod.setDb(pgDb);
+ const app = await createApp({
+ ...(pgDb ? { db: pgDb, valkey: null } : {}),
pasteLimits: { cooldownSeconds: 0 },
pasteLimits: { cooldownSeconds: 0 },
forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 },
forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 },
commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },
commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },
@@ -58,6 +64,6 @@ const path = require('path');
@@ -58,6 +64,6 @@ const path = require('path');
const commentThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref } })).thread;
const commentThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref } })).thread;
if (commentThread) await call(`/api/v1/comments/threads/${commentThread.id}/comments`, { method: 'POST', json: { body: 'A comment from N-1' } });
if (commentThread) await call(`/api/v1/comments/threads/${commentThread.id}/comments`, { method: 'POST', json: { body: 'A comment from N-1' } });
- process.on('SIGTERM', () => { server.close(); try { require(path.join(dir, 'server', 'db')).closeDb(); } catch { /* */ } process.exit(0); });
+ process.on('SIGTERM', async () => { server.close(); try { if (pgDb) await pgDb.close(); else require(path.join(dir, 'server', 'db')).closeDb(); } catch { /* */ } process.exit(0); });
process.stdout.write(`${JSON.stringify({ n1: { url, token, ids: { thread: thread && thread.id, thread_slug: thread && thread.slug, paste: paste && paste.slug, comment_thread: commentThread && commentThread.id, comment_access: commentThread && (commentThread.access_id || commentThread.id) } } })}\n`);
process.stdout.write(`${JSON.stringify({ n1: { url, token, ids: { thread: thread && thread.id, thread_slug: thread && thread.slug, paste: paste && paste.slug, comment_thread: commentThread && commentThread.id, comment_access: commentThread && (commentThread.access_id || commentThread.id) } } })}\n`);
})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });
})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });
@@ -23,12 +23,12 @@ function raw(base, path, headers = {}) {
@@ -23,12 +23,12 @@ function raw(base, path, headers = {}) {
assert.strictEqual(b.status, 'ready');
assert.strictEqual(b.status, 'ready');
assert.strictEqual(b.service, 'community');
assert.strictEqual(b.service, 'community');
assert.strictEqual(b.pastes_authority, 'live');
assert.strictEqual(b.pastes_authority, 'live');
- assert.deepStrictEqual(Object.keys(b.checks), ['db', 'network_jwks', 'live']);
+ assert.deepStrictEqual(Object.keys(b.checks), ['db', 'valkey', 'network_jwks', 'live']);
assert.strictEqual(b.checks.db.required, true);
assert.strictEqual(b.checks.db.required, true);
assert.strictEqual(b.checks.network_jwks.required, false);
assert.strictEqual(b.checks.network_jwks.required, false);
assert.strictEqual(b.checks.live.required, false);
assert.strictEqual(b.checks.live.required, false);
- for (const c of Object.values(b.checks)) {
- assert.strictEqual(c.status, 'ok');
+ for (const [name, c] of Object.entries(b.checks)) {
+ assert.strictEqual(c.status, name === 'valkey' && !process.env.VALKEY_URL ? 'skipped' : 'ok', name); // Valkey under test:pg only
assert.strictEqual(typeof c.latency_ms, 'number');
assert.strictEqual(typeof c.latency_ms, 'number');
assert.ok(Date.parse(c.checked_at));
assert.ok(Date.parse(c.checked_at));
}
}
@@ -189,7 +189,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -189,7 +189,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(p.avatar_url, `${net.url}/avatar/kim`, 'relative Network avatars made absolute');
assert.strictEqual(p.avatar_url, `${net.url}/avatar/kim`, 'relative Network avatars made absolute');
assert.ok(net.resolveCalls.slice(before).some((b) => Array.isArray(b.subject_ids) && b.subject_ids.includes(kim.subject_id)));
assert.ok(net.resolveCalls.slice(before).some((b) => Array.isArray(b.subject_ids) && b.subject_ids.includes(kim.subject_id)));
kim.display_name = 'Kim Renamed';
kim.display_name = 'Kim Renamed';
- t.db.prepare("UPDATE subject_projection SET refreshed_at = datetime('now', '-1 day') WHERE subject_id = ?").run(kim.subject_id);
+ await t.db.prepare("UPDATE subject_projection SET refreshed_at = datetime('now', '-1 day') WHERE subject_id = ?").run(kim.subject_id);
const stale = await call(`/api/pastes/${p.slug}?no_view=1`);
const stale = await call(`/api/pastes/${p.slug}?no_view=1`);
assert.strictEqual(stale.json().paste.display_name, 'Kim', 'stale entry served while it refreshes');
assert.strictEqual(stale.json().paste.display_name, 'Kim', 'stale entry served while it refreshes');
await new Promise((res) => setTimeout(res, 100));
await new Promise((res) => setTimeout(res, 100));
@@ -256,7 +256,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -256,7 +256,7 @@ const { boot, check, done } = require('./helpers/app');
const meta = JSON.parse(p.metadata);
const meta = JSON.parse(p.metadata);
assert.strictEqual(meta.page_url, 'https://example.com/x');
assert.strictEqual(meta.page_url, 'https://example.com/x');
assert.strictEqual(meta.original_name, 'desk.jpg');
assert.strictEqual(meta.original_name, 'desk.jpg');
- const row = t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
+ const row = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
assert.match(row.media_ref, /^med_[0-9A-HJKMNP-TV-Z]{26}$/); assert.strictEqual(row.media_ref, up.key);
assert.match(row.media_ref, /^med_[0-9A-HJKMNP-TV-Z]{26}$/); assert.strictEqual(row.media_ref, up.key);
shotSlug = p.slug;
shotSlug = p.slug;
const viaCreate = new FormData();
const viaCreate = new FormData();
@@ -7,35 +7,36 @@
@@ -7,35 +7,36 @@
*/
*/
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const store = require('../server/pastes/store');
const store = require('../server/pastes/store');
const { createPasteService } = require('../server/pastes/service');
const { createPasteService } = require('../server/pastes/service');
const { check, done } = require('./helpers/app');
const { check, done } = require('./helpers/app');
-const db = openDb(':memory:');
-const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
-const alice = ids.newId('user');
-const viewer = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
-const daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);
+let db, svc, alice, viewer, daysAgo, add, slugs;
-const add = (slug, { origin = 'user', visibility = 'public', views = 0, likes = 0, pinned = 0, age = 1, burn = 0, iso = false } = {}) => {
- const row = store.insertPaste(db, { slug, owner_subject: origin === 'ai' ? null : alice, origin, type: 'paste', title: slug, content: slug, language: 'text', visibility, pinned, burn_after_read: burn });
- const at = iso ? new Date(Date.now() - age * 86400_000).toISOString() : daysAgo(age);
- db.prepare('UPDATE pastes SET views = ?, likes = ?, created_at = ? WHERE id = ?').run(views, likes, at, row.id);
-};
-add('person-new', { age: 1, views: 4 });
-add('person-liked', { age: 2, views: 1, likes: 3 }); // score 16
-add('person-pinned-old', { age: 30, views: 2, pinned: 1 });
-add('person-private', { age: 1, views: 99, visibility: 'private' });
-add('person-unlisted', { age: 1, views: 99, visibility: 'unlisted' });
-add('person-burn', { age: 1, views: 99, burn: 1 });
-add('person-imported-iso', { age: 3, views: 5, iso: true });
-add('ai-moment', { origin: 'ai', age: 1, views: 7 });
-add('ai-old', { origin: 'ai', age: 20, views: 50 });
-
-const slugs = async (q) => (await svc.list(viewer, q)).pastes.map((p) => p.slug);
+ // score 16
(async () => {
(async () => {
+ db = await testDb();
+ svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
+ alice = ids.newId('user');
+ viewer = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
+ daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);
+ add = async (slug, { origin = 'user', visibility = 'public', views = 0, likes = 0, pinned = 0, age = 1, burn = 0, iso = false } = {}) => {
+ const row = await store.insertPaste(db, { slug, owner_subject: origin === 'ai' ? null : alice, origin, type: 'paste', title: slug, content: slug, language: 'text', visibility, pinned, burn_after_read: burn });
+ const at = iso ? new Date(Date.now() - age * 86400_000).toISOString() : daysAgo(age);
+ await db.prepare('UPDATE pastes SET views = ?, likes = ?, created_at = ? WHERE id = ?').run(views, likes, at, row.id);
+ };
+ await add('person-new', { age: 1, views: 4 });
+ await add('person-liked', { age: 2, views: 1, likes: 3 });
+ await add('person-pinned-old', { age: 30, views: 2, pinned: 1 });
+ await add('person-private', { age: 1, views: 99, visibility: 'private' });
+ await add('person-unlisted', { age: 1, views: 99, visibility: 'unlisted' });
+ await add('person-burn', { age: 1, views: 99, burn: 1 });
+ await add('person-imported-iso', { age: 3, views: 5, iso: true });
+ await add('ai-moment', { origin: 'ai', age: 1, views: 7 });
+ await add('ai-old', { origin: 'ai', age: 20, views: 50 });
+ slugs = async (q) => (await svc.list(viewer, q)).pastes.map((p) => p.slug);
await check('origin=user and origin=ai split the public list; hidden pastes are in neither', async () => {
await check('origin=user and origin=ai split the public list; hidden pastes are in neither', async () => {
const people = await slugs({ origin: 'user', pinned_first: '0' });
const people = await slugs({ origin: 'user', pinned_first: '0' });
assert.deepStrictEqual(people, ['person-new', 'person-liked', 'person-imported-iso', 'person-pinned-old']);
assert.deepStrictEqual(people, ['person-new', 'person-liked', 'person-imported-iso', 'person-pinned-old']);
@@ -6,33 +6,32 @@
@@ -6,33 +6,32 @@
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
const { modules } = require('openvibe-contracts');
const { modules } = require('openvibe-contracts');
-const { openDb } = require('../server/db');
-const { summarize, createProfileModule, ensureSchema } = require('../server/identity/profile-module');
+const { testDb } = require('./helpers/db');
+const { summarize, createProfileModule } = require('../server/identity/profile-module');
-const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
-const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
-const db = openDb(':memory:');
-ensureSchema(db);
-const now = Date.parse('2026-09-25T12:00:00Z');
-const at = (msAgo) => new Date(now - msAgo).toISOString().replace('T', ' ').slice(0, 19);
-const DAY = 86400000;
-
-const space = db.prepare("INSERT INTO spaces (slug, name) VALUES ('profile-test', 'Profile test')").run().lastInsertRowid;
-const thread = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, created_at) VALUES (?, ?, ?, ?, ?)').run(space, 'hello', 'Hello', ANN, at(10 * DAY)).lastInsertRowid;
-db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 1, ?, ?)').run(thread, ANN, 'hi', at(10 * DAY));
-db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 0, ?, ?)').run(thread, ANN, 'reply', at(2 * DAY));
-db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at, deleted_at) VALUES (?, ?, 0, ?, ?, ?)').run(thread, ANN, 'gone', at(DAY), at(DAY));
-const ct = db.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', '1')").run().lastInsertRowid;
-db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, ANN, 'nice', at(3 * DAY));
-db.prepare("INSERT INTO comments (thread_id, author_subject, origin, message, created_at) VALUES (?, ?, 'ai', ?, ?)").run(ct, ANN, 'bot', at(3 * DAY));
-db.prepare("INSERT INTO pastes (slug, owner_subject, content, created_at) VALUES ('p1', ?, 'x', ?)").run(ANN, at(5 * DAY));
-db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, created_at) VALUES ('p2', ?, 'x', 'private', ?)").run(ANN, at(5 * DAY));
+let ANN, BOB, db, now, at, DAY, space, thread, ct;
(async () => {
(async () => {
- const s = summarize(db, ANN);
+ ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
+ BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
+ db = await testDb();
+ now = Date.parse('2026-09-25T12:00:00Z');
+ at = (msAgo) => new Date(now - msAgo).toISOString().replace('T', ' ').slice(0, 19);
+ DAY = 86400000;
+ space = (await db.prepare("INSERT INTO spaces (slug, name) VALUES ('profile-test', 'Profile test') RETURNING id").run()).lastInsertRowid;
+ thread = (await db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id').run(space, 'hello', 'Hello', ANN, at(10 * DAY))).lastInsertRowid;
+ await db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 1, ?, ?)').run(thread, ANN, 'hi', at(10 * DAY));
+ await db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 0, ?, ?)').run(thread, ANN, 'reply', at(2 * DAY));
+ await db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at, deleted_at) VALUES (?, ?, 0, ?, ?, ?)').run(thread, ANN, 'gone', at(DAY), at(DAY));
+ ct = (await db.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', '1') RETURNING id").run()).lastInsertRowid;
+ await db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, ANN, 'nice', at(3 * DAY));
+ await db.prepare("INSERT INTO comments (thread_id, author_subject, origin, message, created_at) VALUES (?, ?, 'ai', ?, ?)").run(ct, ANN, 'bot', at(3 * DAY));
+ await db.prepare("INSERT INTO pastes (slug, owner_subject, content, created_at) VALUES ('p1', ?, 'x', ?)").run(ANN, at(5 * DAY));
+ await db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, created_at) VALUES ('p2', ?, 'x', 'private', ?)").run(ANN, at(5 * DAY));
+ const s = await summarize(db, ANN);
assert.deepStrictEqual(s, { threads: 1, posts: 1, comments: 1, pastes: 1, first_active_at: new Date(now - 10 * DAY).toISOString(), last_active_at: new Date(now - 2 * DAY).toISOString() });
assert.deepStrictEqual(s, { threads: 1, posts: 1, comments: 1, pastes: 1, first_active_at: new Date(now - 10 * DAY).toISOString(), last_active_at: new Date(now - 2 * DAY).toISOString() });
assert.ok(modules.validateData('community.profile', s).valid, 'matches the namespace schema');
assert.ok(modules.validateData('community.profile', s).valid, 'matches the namespace schema');
- assert.strictEqual(summarize(db, BOB), null, 'nothing written: no record');
+ assert.strictEqual(await summarize(db, BOB), null, 'nothing written: no record');
const puts = [];
const puts = [];
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
@@ -56,8 +55,8 @@ db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, create
@@ -56,8 +55,8 @@ db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, create
assert.strictEqual(await mod.push('gst_01JAB2C3D4E5F6G7H8J9K0MNPG'), false, 'guests have no record');
assert.strictEqual(await mod.push('gst_01JAB2C3D4E5F6G7H8J9K0MNPG'), false, 'guests have no record');
// The scan: Bob comments, Ann deletes her reply.
// The scan: Bob comments, Ann deletes her reply.
- db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, BOB, 'first!', at(60000));
- db.prepare("UPDATE posts SET deleted_at = ? WHERE body_markdown = 'reply'").run(at(30000));
+ await db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, BOB, 'first!', at(60000));
+ await db.prepare("UPDATE posts SET deleted_at = ? WHERE body_markdown = 'reply'").run(at(30000));
assert.strictEqual(await mod.scan({ now }), 2);
assert.strictEqual(await mod.scan({ now }), 2);
assert.deepStrictEqual(puts.slice(1).map((p) => [p.subject, p.data.comments, p.data.posts]).sort(), [[ANN, 1, 0], [BOB, 1, 0]]);
assert.deepStrictEqual(puts.slice(1).map((p) => [p.subject, p.data.comments, p.data.posts]).sort(), [[ANN, 1, 0], [BOB, 1, 0]]);
assert.strictEqual(await mod.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');
assert.strictEqual(await mod.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');
@@ -70,12 +70,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -70,12 +70,14 @@ const { boot, check, done } = require('./helpers/app');
await check('an unreachable Live answers 502 JSON instead of hanging', async () => {
await check('an unreachable Live answers 502 JSON instead of hanging', async () => {
const { createApp } = require('../server/app');
const { createApp } = require('../server/app');
const http = require('http');
const http = require('http');
- const app = createApp({ liveUrl: 'http://127.0.0.1:1' });
+ const db = await require('./helpers/db').testDb();
+ const app = await createApp({ liveUrl: 'http://127.0.0.1:1', db });
const srv = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const srv = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/pastes`);
const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/pastes`);
assert.strictEqual(res.status, 502);
assert.strictEqual(res.status, 502);
assert.deepStrictEqual(await res.json(), { error: 'Could not reach the paste service' });
assert.deepStrictEqual(await res.json(), { error: 'Could not reach the paste service' });
await new Promise((r) => srv.close(r));
await new Promise((r) => srv.close(r));
+ await db.close();
});
});
await t.close();
await t.close();
@@ -4,36 +4,37 @@ const assert = require('assert');
@@ -4,36 +4,37 @@ const assert = require('assert');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const { createPulseConsumer } = require('../server/pulse/consumer');
const { createPulseConsumer } = require('../server/pulse/consumer');
-const SECRET = `whsec_${'ab'.repeat(32)}`;
-const db = openDb(':memory:');
-const app = express();
-const vipDrops = [];
-app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], vipCache: { handleEvent: (e) => { vipDrops.push(e.event_id); return true; } }, log: { error() {}, log() {}, warn() {} } }).router);
-const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
-let seq = 0;
-const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).padStart(3, '0')}`.slice(0, 30), event_type: 'live.stream.started', version: 1, source: 'live', visibility: 'public', timestamp: new Date().toISOString(),
- subject: { type: 'stream', id: '2477', revision: 1 }, actor: { type: 'user', id: USR },
- payload: { stream_id: 2477, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: USR } }, title: 'Night walk', started_at: new Date().toISOString() }, ...over });
+let SECRET, db, app, vipDrops, USR, seq, evt;
(async () => {
(async () => {
+ SECRET = `whsec_${'ab'.repeat(32)}`;
+ db = await testDb();
+ app = express();
+ vipDrops = [];
+ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], vipCache: { handleEvent: (e) => { vipDrops.push(e.event_id); return true; } }, log: { error() {}, log() {}, warn() {} } }).router);
+ USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ seq = 0;
+ evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).padStart(3, '0')}`.slice(0, 30), event_type: 'live.stream.started', version: 1, source: 'live', visibility: 'public', timestamp: new Date().toISOString(),
+ subject: { type: 'stream', id: '2477', revision: 1 }, actor: { type: 'user', id: USR },
+ payload: { stream_id: 2477, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: USR } }, title: 'Night walk', started_at: new Date().toISOString() }, ...over });
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const post = (event, { secret = SECRET, headers = {} } = {}) => {
const post = (event, { secret = SECRET, headers = {} } = {}) => {
const body = JSON.stringify({ event, seq: 1 });
const body = JSON.stringify({ event, seq: 1 });
return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })
return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })
.then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
.then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
};
};
- const items = () => db.prepare('SELECT source_service, source_type, source_id, title, url, actor_subject, origin FROM pulse_items ORDER BY id').all();
+ const items = async () => await db.prepare('SELECT source_service, source_type, source_id, title, url, actor_subject, origin FROM pulse_items ORDER BY id').all();
const e1 = evt();
const e1 = evt();
let r = await post(e1);
let r = await post(e1);
assert.strictEqual(r.status, 200); assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual(r.status, 200); assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.deepStrictEqual(items(), [{ source_service: 'live', source_type: 'stream', source_id: '2477', title: 'Goosely went live: Night walk', url: 'https://openvibe.live/@goosely', actor_subject: USR, origin: 'user' }]);
+ assert.deepStrictEqual(await items(), [{ source_service: 'live', source_type: 'stream', source_id: '2477', title: 'Goosely went live: Night walk', url: 'https://openvibe.live/@goosely', actor_subject: USR, origin: 'user' }]);
r = await post(e1);
r = await post(e1);
assert.strictEqual(r.json.duplicate, true, 'a redelivery does nothing');
assert.strictEqual(r.json.duplicate, true, 'a redelivery does nothing');
- assert.strictEqual(items().length, 1);
+ assert.strictEqual((await items()).length, 1);
assert.strictEqual((await post(evt(), { secret: `whsec_${'cd'.repeat(32)}` })).status, 401, 'forged signature');
assert.strictEqual((await post(evt(), { secret: `whsec_${'cd'.repeat(32)}` })).status, 401, 'forged signature');
assert.strictEqual((await post(evt(), { headers: { 'x-forwarded-for': '1.2.3.4' } })).status, 403, 'came through the proxy');
assert.strictEqual((await post(evt(), { headers: { 'x-forwarded-for': '1.2.3.4' } })).status, 403, 'came through the proxy');
@@ -42,12 +43,12 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
@@ -42,12 +43,12 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
r = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },
r = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },
payload: { blog: 'openvibe', canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));
payload: { blog: 'openvibe', canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.strictEqual(items()[1].title, 'New post on the openvibe blog');
- assert.strictEqual(items()[1].origin, 'system', 'no person when a service published it');
+ assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');
+ assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');
r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_2' },
r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_2' },
payload: { canonical_url: 'https://openvibe.wiki/w/help/start', publication_state: 'published', visibility: 'public', space: 'help', slug: 'start', indexability: { decision: 'noindex', reasons: ['thin'] } } }));
payload: { canonical_url: 'https://openvibe.wiki/w/help/start', publication_state: 'published', visibility: 'public', space: 'help', slug: 'start', indexability: { decision: 'noindex', reasons: ['thin'] } } }));
assert.strictEqual(r.json.outcome, 'ignored:not_public', 'a noindex page stays out of Pulse');
assert.strictEqual(r.json.outcome, 'ignored:not_public', 'a noindex page stays out of Pulse');
- assert.strictEqual(items().length, 2);
+ assert.strictEqual((await items()).length, 2);
// VIP convergence: a membership change from VIP drops cached members-only answers once; from anyone else, nothing.
// VIP convergence: a membership change from VIP drops cached members-only answers once; from anyone else, nothing.
const vipEv = (source) => evt({ event_type: 'vip.membership.changed', source, subject: { type: 'membership', id: 'mbr_1', revision: 2 }, actor: { type: 'service', id: 'vip' },
const vipEv = (source) => evt({ event_type: 'vip.membership.changed', source, subject: { type: 'membership', id: 'mbr_1', revision: 2 }, actor: { type: 'service', id: 'vip' },
payload: { member: { type: 'user', id: USR }, creator: { type: 'user', id: USR }, status: 'canceled' } });
payload: { member: { type: 'user', id: USR }, creator: { type: 'user', id: USR }, status: 'canceled' } });
@@ -59,7 +60,7 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
@@ -59,7 +60,7 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
r = await post(vipEv('live'));
r = await post(vipEv('live'));
assert.strictEqual(r.json.outcome, 'ignored:source');
assert.strictEqual(r.json.outcome, 'ignored:source');
assert.deepStrictEqual(vipDrops, [v1.event_id]);
assert.deepStrictEqual(vipDrops, [v1.event_id]);
- assert.strictEqual(items().length, 2, 'no Pulse item for a membership');
+ assert.strictEqual((await items()).length, 2, 'no Pulse item for a membership');
// Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.
// Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.
const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },
const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },
@@ -71,19 +72,19 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
@@ -71,19 +72,19 @@ const evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).p
const m11 = mod(11);
const m11 = mod(11);
r = await post(m11);
r = await post(m11);
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.deepStrictEqual(items()[2], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });
+ assert.deepStrictEqual((await items())[2], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });
assert.strictEqual((await post(m11)).json.duplicate, true);
assert.strictEqual((await post(m11)).json.duplicate, true);
r = await post(mod(6));
r = await post(mod(6));
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');
r = await post(mod(12));
r = await post(mod(12));
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');
assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');
r = await post(mod(15));
r = await post(mod(15));
- assert.strictEqual(items()[3].title, 'Reached level 15 in Scraplandia');
+ assert.strictEqual((await items())[3].title, 'Reached level 15 in Scraplandia');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');
assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');
assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');
assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');
- assert.strictEqual(items().length, 4);
+ assert.strictEqual((await items()).length, 4);
srv.close();
srv.close();
console.log('pulse consumer: all checks passed');
console.log('pulse consumer: all checks passed');
process.exit(0);
process.exit(0);
@@ -21,8 +21,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -21,8 +21,8 @@ const { boot, check, done } = require('./helpers/app');
const forum = t.app.locals.forum;
const forum = t.app.locals.forum;
const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
const PULSE = 'community.pulse.write';
const PULSE = 'community.pulse.write';
- const items = () => t.db.prepare('SELECT * FROM pulse_items ORDER BY id').all();
- const bySource = (type, id) => t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = ? AND source_id = ?").get(type, String(id));
+ const items = async () => await t.db.prepare('SELECT * FROM pulse_items ORDER BY id').all();
+ const bySource = async (type, id) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = ? AND source_id = ?").get(type, String(id));
const call = (path, { method = 'GET', token, headers = {}, json } = {}) => {
const call = (path, { method = 'GET', token, headers = {}, json } = {}) => {
const h = { ...headers };
const h = { ...headers };
if (token) h.authorization = `Bearer ${token}`;
if (token) h.authorization = `Bearer ${token}`;
@@ -38,11 +38,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -38,11 +38,11 @@ const { boot, check, done } = require('./helpers/app');
const burn = (await pastes.createText(asAlex, { content: 'b', burn_after_read: true })).slug;
const burn = (await pastes.createText(asAlex, { content: 'b', burn_after_read: true })).slug;
const nsfw = (await pastes.createText(asAlex, { content: 'n', is_nsfw: true })).slug;
const nsfw = (await pastes.createText(asAlex, { content: 'n', is_nsfw: true })).slug;
const ai = (await pastes.createText({ kind: 'service', subject: null, origin: 'ai', claims: { cap: [] } }, { content: 'a' })).slug;
const ai = (await pastes.createText({ kind: 'service', subject: null, origin: 'ai', claims: { cap: [] } }, { content: 'a' })).slug;
- const it = bySource('paste', pub);
+ const it = await bySource('paste', pub);
assert.ok(it, 'public paste recorded');
assert.ok(it, 'public paste recorded');
assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Public one', `https://openvibe.community/p/${pub}`, alex.subject_id, 'user', 'public']);
assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Public one', `https://openvibe.community/p/${pub}`, alex.subject_id, 'user', 'public']);
- assert.strictEqual(bySource('paste', anonPub).actor_subject, null);
- for (const s of [unl, priv, burn, nsfw, ai]) assert.strictEqual(bySource('paste', s), undefined, `${s} must not enter Pulse`);
+ assert.strictEqual((await bySource('paste', anonPub)).actor_subject, null);
+ for (const s of [unl, priv, burn, nsfw, ai]) assert.strictEqual(await bySource('paste', s), undefined, `${s} must not enter Pulse`);
});
});
await check('pastes leave Pulse when made private or deleted; read-time guard covers what the hooks miss', async () => {
await check('pastes leave Pulse when made private or deleted; read-time guard covers what the hooks miss', async () => {
@@ -50,29 +50,29 @@ const { boot, check, done } = require('./helpers/app');
@@ -50,29 +50,29 @@ const { boot, check, done } = require('./helpers/app');
const b = (await pastes.createText(asAlex, { title: 'Soon deleted', content: '2' })).slug;
const b = (await pastes.createText(asAlex, { title: 'Soon deleted', content: '2' })).slug;
const c = (await pastes.createText(asAlex, { title: 'Changed behind our back', content: '3' })).slug;
const c = (await pastes.createText(asAlex, { title: 'Changed behind our back', content: '3' })).slug;
await pastes.update(asAlex, a, { visibility: 'private' });
await pastes.update(asAlex, a, { visibility: 'private' });
- pastes.remove(asAlex, b);
- assert.strictEqual(bySource('paste', a), undefined);
- assert.strictEqual(bySource('paste', b), undefined);
- t.db.prepare("UPDATE pastes SET visibility = 'unlisted' WHERE slug = ?").run(c); // no hook ran
- assert.ok(bySource('paste', c), 'row still there');
+ await pastes.remove(asAlex, b);
+ assert.strictEqual(await bySource('paste', a), undefined);
+ assert.strictEqual(await bySource('paste', b), undefined);
+ await t.db.prepare("UPDATE pastes SET visibility = 'unlisted' WHERE slug = ?").run(c); // no hook ran
+ assert.ok(await bySource('paste', c), 'row still there');
const listed = (await call('/api/v1/pulse?limit=100')).json().items.map((i) => i.source.id);
const listed = (await call('/api/v1/pulse?limit=100')).json().items.map((i) => i.source.id);
assert.ok(!listed.includes(c), 'but never listed');
assert.ok(!listed.includes(c), 'but never listed');
});
});
await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {
await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {
const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });
const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });
- const it = bySource('thread', thread.id);
+ const it = await bySource('thread', thread.id);
assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/s/general/t/pulse-thread', alex.subject_id, 'user']);
assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/s/general/t/pulse-thread', alex.subject_id, 'user']);
const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });
const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });
- assert.strictEqual(bySource('post', post.id).title, 'Re: Pulse thread');
- assert.strictEqual(bySource('post', post.id).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);
- t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
+ assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');
+ assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);
+ await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });
const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });
- assert.strictEqual(bySource('thread', hidden.thread.id), undefined);
- forum.deletePost(asAlex, post.id);
- assert.strictEqual(bySource('post', post.id), undefined);
- forum.deleteThread(asAlex, 'general', 'pulse-thread');
- assert.strictEqual(bySource('thread', thread.id), undefined);
+ assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);
+ await forum.deletePost(asAlex, post.id);
+ assert.strictEqual(await bySource('post', post.id), undefined);
+ await forum.deleteThread(asAlex, 'general', 'pulse-thread');
+ assert.strictEqual(await bySource('thread', thread.id), undefined);
});
});
await check('POST /items: services with community.pulse.write only; browsers and other caps refused', async () => {
await check('POST /items: services with community.pulse.write only; browsers and other caps refused', async () => {
@@ -109,7 +109,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -109,7 +109,7 @@ const { boot, check, done } = require('./helpers/app');
const it = again.json().item;
const it = again.json().item;
assert.strictEqual(it.title, 'A VOD (renamed)');
assert.strictEqual(it.title, 'A VOD (renamed)');
assert.deepStrictEqual([it.origin, it.actor && it.actor.subject, it.occurred_at], ['user', alex.subject_id, '2026-09-20T10:00:00.000Z'], 'origin, actor and time are the first record\'s');
assert.deepStrictEqual([it.origin, it.actor && it.actor.subject, it.occurred_at], ['user', alex.subject_id, '2026-09-20T10:00:00.000Z'], 'origin, actor and time are the first record\'s');
- assert.strictEqual(items().filter((i) => i.source_id === 'v9').length, 1);
+ assert.strictEqual((await items()).filter((i) => i.source_id === 'v9').length, 1);
});
});
await check('AI items: labelled AI, never attributed (X-OV-Origin: ai or origin ai), system items name no one', async () => {
await check('AI items: labelled AI, never attributed (X-OV-Origin: ai or origin ai), system items name no one', async () => {
@@ -118,7 +118,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -118,7 +118,7 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual([viaHeader.json().item.origin, viaHeader.json().item.actor, viaHeader.json().item.label], ['ai', null, 'AI']);
assert.deepStrictEqual([viaHeader.json().item.origin, viaHeader.json().item.actor, viaHeader.json().item.label], ['ai', null, 'AI']);
const viaBody = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm2' }, title: 'Summary', url: 'https://openvibe.live/m/2', origin: 'ai' } });
const viaBody = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm2' }, title: 'Summary', url: 'https://openvibe.live/m/2', origin: 'ai' } });
assert.deepStrictEqual([viaBody.json().item.origin, viaBody.json().item.actor], ['ai', null]);
assert.deepStrictEqual([viaBody.json().item.origin, viaBody.json().item.actor], ['ai', null]);
- assert.strictEqual(t.db.prepare("SELECT actor_subject FROM pulse_items WHERE source_id = 'm2'").get().actor_subject, null);
+ assert.strictEqual((await t.db.prepare("SELECT actor_subject FROM pulse_items WHERE source_id = 'm2'").get()).actor_subject, null);
const sys = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'event', id: 'e1' }, title: 'Maintenance tonight', url: 'https://openvibe.live/status', origin: 'system' } });
const sys = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'event', id: 'e1' }, title: 'Maintenance tonight', url: 'https://openvibe.live/status', origin: 'system' } });
assert.deepStrictEqual([sys.json().item.origin, sys.json().item.actor, sys.json().item.label], ['system', null, 'System']);
assert.deepStrictEqual([sys.json().item.origin, sys.json().item.actor, sys.json().item.label], ['system', null, 'System']);
});
});
@@ -16,7 +16,7 @@ const { createEventsClient } = require('openvibe-sdk/events');
@@ -16,7 +16,7 @@ const { createEventsClient } = require('openvibe-sdk/events');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { startWebhooks } = require('./helpers/fake-discord');
const { startWebhooks } = require('./helpers/fake-discord');
const { startEvents } = require('./helpers/fake-events');
const { startEvents } = require('./helpers/fake-events');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const forumStore = require('../server/forum/store');
const forumStore = require('../server/forum/store');
const { createDiscordRelay } = require('../server/relay/discord');
const { createDiscordRelay } = require('../server/relay/discord');
const { createRelayEventsWorker, CURSOR } = require('../server/relay/events-worker');
const { createRelayEventsWorker, CURSOR } = require('../server/relay/events-worker');
@@ -32,125 +32,125 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.crea
@@ -32,125 +32,125 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.crea
(async () => {
(async () => {
const hook = await startWebhooks();
const hook = await startWebhooks();
const events = await startEvents();
const events = await startEvents();
- const db = openDb(':memory:');
- const general = forumStore.getSpace(db, 'general');
+ const db = await testDb();
+ const general = await forumStore.getSpace(db, 'general');
const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` };
const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` };
const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env, enabled: true, baseMs: 1000, maxAttempts: 3 });
const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env, enabled: true, baseMs: 1000, maxAttempts: 3 });
- relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
+ await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
const client = createEventsClient(createClient({ baseUrls: { events: events.url }, getToken: async () => 'test-token', retries: 0 }), { source: 'community' });
const client = createEventsClient(createClient({ baseUrls: { events: events.url }, getToken: async () => 'test-token', retries: 0 }), { source: 'community' });
const worker = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000, pageLimit: 2 });
const worker = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000, pageLimit: 2 });
relay.attach({ worker });
relay.attach({ worker });
const alex = ids.newId('user');
const alex = ids.newId('user');
- const newThread = (title, extra = {}) => forumStore.createThread(db, { space_id: general.id, title, author_subject: alex, body_markdown: `${title} body`, ...extra }).thread;
- const deliveries = () => db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();
+ const newThread = async (title, extra = {}) => (await forumStore.createThread(db, { space_id: general.id, title, author_subject: alex, body_markdown: `${title} body`, ...extra })).thread;
+ const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();
const settle = () => new Promise((r) => setImmediate(r));
const settle = () => new Promise((r) => setImmediate(r));
await check('off without Events or the client secret, or with DISCORD_RELAY_EVENTS=off; the forum queues creates then', async () => {
await check('off without Events or the client secret, or with DISCORD_RELAY_EVENTS=off; the forum queues creates then', async () => {
const a = createRelayEventsWorker({ db, relay, eventsUrl: null, clientSecret: 'x', tokenUrl: 'http://127.0.0.1:9/oauth/token' });
const a = createRelayEventsWorker({ db, relay, eventsUrl: null, clientSecret: 'x', tokenUrl: 'http://127.0.0.1:9/oauth/token' });
- assert.deepStrictEqual([a.enabled, a.status().reason], [false, 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set']);
+ assert.deepStrictEqual([a.enabled, (await a.status()).reason], [false, 'EVENTS_URL and OV_OAUTH_CLIENT_SECRET are not both set']);
const b = createRelayEventsWorker({ db, relay, eventsUrl: events.url, clientSecret: '', tokenUrl: 'http://127.0.0.1:9/oauth/token' });
const b = createRelayEventsWorker({ db, relay, eventsUrl: events.url, clientSecret: '', tokenUrl: 'http://127.0.0.1:9/oauth/token' });
assert.strictEqual(b.enabled, false);
assert.strictEqual(b.enabled, false);
const c = createRelayEventsWorker({ db, relay, events: client, enabled: false });
const c = createRelayEventsWorker({ db, relay, events: client, enabled: false });
- assert.deepStrictEqual([c.enabled, c.status().reason], [false, 'DISCORD_RELAY_EVENTS=off']);
+ assert.deepStrictEqual([c.enabled, (await c.status()).reason], [false, 'DISCORD_RELAY_EVENTS=off']);
const d = createRelayEventsWorker({ db, relay: createDiscordRelay({ db, env }), events: client });
const d = createRelayEventsWorker({ db, relay: createDiscordRelay({ db, env }), events: client });
- assert.match(d.status().reason, /relay is off/);
+ assert.match((await d.status()).reason, /relay is off/);
c.start();
c.start();
- assert.strictEqual(relay.status().creates_from, 'forum');
+ assert.strictEqual((await relay.status()).creates_from, 'forum');
assert.deepStrictEqual(await c.tick(), { queued: 0 });
assert.deepStrictEqual(await c.tick(), { queued: 0 });
});
});
let before;
let before;
await check('first start begins at the head: threads from before are never announced', async () => {
await check('first start begins at the head: threads from before are never announced', async () => {
- const old1 = newThread('Older one');
- const old2 = newThread('Older two');
+ const old1 = await newThread('Older one');
+ const old2 = await newThread('Older two');
events.add(threadEvent(old1));
events.add(threadEvent(old1));
events.add(threadEvent(old2));
events.add(threadEvent(old2));
events.add(envelope('community.paste.created', { paste_id: 'x' }));
events.add(envelope('community.paste.created', { paste_id: 'x' }));
relay.start(); // starts the worker too (its interval is an hour here: ticks are driven by the test)
relay.start(); // starts the worker too (its interval is an hour here: ticks are driven by the test)
await worker.tick();
await worker.tick();
- assert.strictEqual(relay.status().creates_from, 'events');
- const st = worker.status();
+ assert.strictEqual((await relay.status()).creates_from, 'events');
+ const st = await worker.status();
assert.deepStrictEqual([st.enabled, st.cursor, st.latest_seq, st.started_at_seq, st.lag], [true, 3, 3, 3, 0]);
assert.deepStrictEqual([st.enabled, st.cursor, st.latest_seq, st.started_at_seq, st.lag], [true, 3, 3, 3, 0]);
- assert.strictEqual(deliveries().length, 0);
- assert.deepStrictEqual(db.prepare('SELECT name, cursor FROM relay_cursors').all(), [{ name: CURSOR, cursor: 3 }]);
+ assert.strictEqual((await deliveries()).length, 0);
+ assert.deepStrictEqual(await db.prepare('SELECT name, cursor FROM relay_cursors').all(), [{ name: CURSOR, cursor: 3 }]);
before = events.calls.length;
before = events.calls.length;
});
});
let t1;
let t1;
let p1;
let p1;
await check('new events queue creates (source events, the event id) that are sent once; the forum\'s own calls queue none', async () => {
await check('new events queue creates (source events, the event id) that are sent once; the forum\'s own calls queue none', async () => {
- t1 = newThread('From the stream');
- assert.strictEqual(relay.enqueueThread(t1, general), 0, 'the forum queues no creates while the worker runs');
- p1 = forumStore.addPost(db, { thread_id: t1.id, author_subject: alex, body_markdown: 'a reply via events' });
- assert.strictEqual(relay.enqueuePost(p1, t1, general), 0);
+ t1 = await newThread('From the stream');
+ assert.strictEqual(await relay.enqueueThread(t1, general), 0, 'the forum queues no creates while the worker runs');
+ p1 = await forumStore.addPost(db, { thread_id: t1.id, author_subject: alex, body_markdown: 'a reply via events' });
+ assert.strictEqual(await relay.enqueuePost(p1, t1, general), 0);
const e1 = threadEvent(t1);
const e1 = threadEvent(t1);
const e2 = postEvent(p1, t1);
const e2 = postEvent(p1, t1);
events.add(e1);
events.add(e1);
events.add(envelope('community.comment.created', { comment_id: 1 })); // not on the relay's topics: never returned
events.add(envelope('community.comment.created', { comment_id: 1 })); // not on the relay's topics: never returned
events.add(e2);
events.add(e2);
assert.deepStrictEqual(await worker.tick(), { queued: 2 });
assert.deepStrictEqual(await worker.tick(), { queued: 2 });
- const rows = deliveries();
+ const rows = await deliveries();
assert.deepStrictEqual(rows.map((d) => [d.action, d.source, d.event_id, d.post_id]), [['create', 'events', e1.event_id, null], ['create', 'events', e2.event_id, p1.id]]);
assert.deepStrictEqual(rows.map((d) => [d.action, d.source, d.event_id, d.post_id]), [['create', 'events', e1.event_id, null], ['create', 'events', e2.event_id, p1.id]]);
assert.ok(events.calls.slice(before).every((c) => /topic=community\.thread\.\*%2Ccommunity\.post\.\*/.test(c.path) && c.auth === 'Bearer test-token'), JSON.stringify(events.calls.slice(before)));
assert.ok(events.calls.slice(before).every((c) => /topic=community\.thread\.\*%2Ccommunity\.post\.\*/.test(c.path) && c.auth === 'Bearer test-token'), JSON.stringify(events.calls.slice(before)));
await settle(); await relay.drain(); await relay.drain();
await settle(); await relay.drain(); await relay.drain();
assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);
assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);
assert.match(hook.hits[1].body.content, /replied to \*\*From the stream\*\*/);
assert.match(hook.hits[1].body.content, /replied to \*\*From the stream\*\*/);
- assert.deepStrictEqual([worker.status().cursor, worker.status().lag], [6, 0]);
+ assert.deepStrictEqual([(await worker.status()).cursor, (await worker.status()).lag], [6, 0]);
});
});
await check('a replay (cursor moved back) queues nothing twice and posts nothing twice', async () => {
await check('a replay (cursor moved back) queues nothing twice and posts nothing twice', async () => {
- db.prepare('UPDATE relay_cursors SET cursor = 3 WHERE name = ?').run(CURSOR);
+ await db.prepare('UPDATE relay_cursors SET cursor = 3 WHERE name = ?').run(CURSOR);
assert.deepStrictEqual(await worker.tick(), { queued: 0 });
assert.deepStrictEqual(await worker.tick(), { queued: 0 });
await relay.drain();
await relay.drain();
assert.strictEqual(hook.hits.length, 2);
assert.strictEqual(hook.hits.length, 2);
- assert.strictEqual(worker.status().cursor, 6);
+ assert.strictEqual((await worker.status()).cursor, 6);
});
});
await check('the cursor survives a restart: a new worker carries on from it (no second "start at head")', async () => {
await check('the cursor survives a restart: a new worker carries on from it (no second "start at head")', async () => {
- const t2 = newThread('While restarting');
+ const t2 = await newThread('While restarting');
events.add(threadEvent(t2));
events.add(threadEvent(t2));
const again = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000 });
const again = createRelayEventsWorker({ db, relay, events: client, pollMs: 3_600_000 });
assert.deepStrictEqual(await again.tick(), { queued: 1 });
assert.deepStrictEqual(await again.tick(), { queued: 1 });
- assert.strictEqual(again.status().started_at_seq, null);
- assert.strictEqual(deliveries().pop().thread_id, t2.id);
+ assert.strictEqual((await again.status()).started_at_seq, null);
+ assert.strictEqual((await deliveries()).pop().thread_id, t2.id);
});
});
await check('members-only, Discord-origin, redacted and foreign events queue nothing', async () => {
await check('members-only, Discord-origin, redacted and foreign events queue nothing', async () => {
- const n = deliveries().length;
- const gated = newThread('Gated', { members_only_owner: alex });
- const fromDiscord = forumStore.addPost(db, { thread_id: t1.id, origin: 'discord', body_markdown: 'hi from discord', relay_author: 'Kim' });
- const t3 = newThread('Looks public');
+ const n = (await deliveries()).length;
+ const gated = await newThread('Gated', { members_only_owner: alex });
+ const fromDiscord = await forumStore.addPost(db, { thread_id: t1.id, origin: 'discord', body_markdown: 'hi from discord', relay_author: 'Kim' });
+ const t3 = await newThread('Looks public');
events.add(threadEvent(gated, 'members'));
events.add(threadEvent(gated, 'members'));
events.add(threadEvent(gated)); // even a (wrongly) public-looking event: the row decides
events.add(threadEvent(gated)); // even a (wrongly) public-looking event: the row decides
events.add(postEvent(fromDiscord, t1));
events.add(postEvent(fromDiscord, t1));
events.add({ ...threadEvent(t3), source: 'live' });
events.add({ ...threadEvent(t3), source: 'live' });
events.add({ ...threadEvent(t3), payload: { redacted: true } });
events.add({ ...threadEvent(t3), payload: { redacted: true } });
assert.deepStrictEqual(await worker.tick(), { queued: 0 });
assert.deepStrictEqual(await worker.tick(), { queued: 0 });
- assert.strictEqual(deliveries().length, n);
+ assert.strictEqual((await deliveries()).length, n);
});
});
await check('Events down: the error shows, the cursor stays, and it catches up once Events answers', async () => {
await check('Events down: the error shows, the cursor stays, and it catches up once Events answers', async () => {
- const t4 = newThread('After the outage');
+ const t4 = await newThread('After the outage');
events.add(threadEvent(t4));
events.add(threadEvent(t4));
- const cursor = worker.status().cursor;
+ const cursor = (await worker.status()).cursor;
events.state.failNext = 1;
events.state.failNext = 1;
assert.deepStrictEqual(await worker.tick(), { queued: 0 });
assert.deepStrictEqual(await worker.tick(), { queued: 0 });
- const st = worker.status();
+ const st = await worker.status();
assert.ok(st.last_error, 'the error is shown');
assert.ok(st.last_error, 'the error is shown');
assert.strictEqual(st.cursor, cursor);
assert.strictEqual(st.cursor, cursor);
assert.strictEqual(st.lag, st.latest_seq - cursor);
assert.strictEqual(st.lag, st.latest_seq - cursor);
assert.deepStrictEqual(await worker.tick(), { queued: 1 });
assert.deepStrictEqual(await worker.tick(), { queued: 1 });
- assert.strictEqual(worker.status().last_error, null);
+ assert.strictEqual((await worker.status()).last_error, null);
});
});
await check('a retention gap is logged and shown to staff; reading carries on after it', async () => {
await check('a retention gap is logged and shown to staff; reading carries on after it', async () => {
- db.prepare('UPDATE relay_cursors SET cursor = 1 WHERE name = ?').run(CURSOR);
+ await db.prepare('UPDATE relay_cursors SET cursor = 1 WHERE name = ?').run(CURSOR);
events.state.prunedThrough = 5;
events.state.prunedThrough = 5;
await worker.tick();
await worker.tick();
- assert.deepStrictEqual([worker.status().last_gap.from_seq, worker.status().last_gap.to_seq], [2, 5]);
- assert.strictEqual(worker.status().cursor, events.log.length);
+ assert.deepStrictEqual([(await worker.status()).last_gap.from_seq, (await worker.status()).last_gap.to_seq], [2, 5]);
+ assert.strictEqual((await worker.status()).cursor, events.log.length);
events.state.prunedThrough = 0;
events.state.prunedThrough = 0;
- assert.strictEqual(relay.status().events_worker.last_gap.to_seq, 5);
+ assert.strictEqual((await relay.status()).events_worker.last_gap.to_seq, 5);
});
});
await check('the whole path in the app: API write → outbox → Events → worker → Discord, and back out on edit and delete', async () => {
await check('the whole path in the app: API write → outbox → Events → worker → Discord, and back out on edit and delete', async () => {
@@ -170,9 +170,9 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.crea
@@ -170,9 +170,9 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.crea
const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });
const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- const waitFor = async (fn, what) => { for (let i = 0; i < 200; i++) { if (fn()) return; await new Promise((r) => setTimeout(r, 20)); } assert.fail(`timed out waiting for ${what}`); };
+ const waitFor = async (fn, what) => { for (let i = 0; i < 200; i++) { if (await fn()) return; await new Promise((r) => setTimeout(r, 20)); } assert.fail(`timed out waiting for ${what}`); };
assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 201);
assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 201);
- await waitFor(() => t.app.locals.relay.status().events_worker.cursor != null, 'the worker\'s first read');
+ await waitFor(async () => (await t.app.locals.relay.status()).events_worker.cursor != null, 'the worker\'s first read');
const st = (await call('/api/v1/relay/status', { cookie: adminJwt })).json();
const st = (await call('/api/v1/relay/status', { cookie: adminJwt })).json();
assert.deepStrictEqual([st.creates_from, st.events_worker.enabled], ['events', true]);
assert.deepStrictEqual([st.creates_from, st.events_worker.enabled], ['events', true]);
const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Round trip', body: 'through Events' } });
const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Round trip', body: 'through Events' } });
@@ -180,7 +180,7 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.crea
@@ -180,7 +180,7 @@ const postEvent = (p, t, visibility = 'public') => envelope('community.post.crea
await waitFor(() => hook2.hits.length >= 1, 'the thread on Discord');
await waitFor(() => hook2.hits.length >= 1, 'the thread on Discord');
assert.strictEqual(hook2.hits[0].path, '/api/webhooks/7/app?wait=true');
assert.strictEqual(hook2.hits[0].path, '/api/webhooks/7/app?wait=true');
assert.ok(events2.log.some((x) => x.event.event_type === 'community.thread.created'), 'it went through Events');
assert.ok(events2.log.some((x) => x.event.event_type === 'community.thread.created'), 'it went through Events');
- const d = t.db.prepare('SELECT * FROM relay_deliveries').get();
+ const d = await t.db.prepare('SELECT * FROM relay_deliveries').get();
assert.deepStrictEqual([d.source, /^evt_/.test(d.event_id)], ['events', true]);
assert.deepStrictEqual([d.source, /^evt_/.test(d.event_id)], ['events', true]);
const reply = await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'and a reply' } });
const reply = await call(`/api/v1/spaces/general/threads/${th.json().thread.slug}/posts`, { method: 'POST', cookie: samJwt, json: { body: 'and a reply' } });
await waitFor(() => hook2.hits.length >= 2, 'the reply on Discord');
await waitFor(() => hook2.hits.length >= 2, 'the reply on Discord');
@@ -15,7 +15,7 @@ const assert = require('assert');
@@ -15,7 +15,7 @@ const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { startWebhooks, createGateway } = require('./helpers/fake-discord');
const { startWebhooks, createGateway } = require('./helpers/fake-discord');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const forumStore = require('../server/forum/store');
const forumStore = require('../server/forum/store');
const { createDiscordRelay } = require('../server/relay/discord');
const { createDiscordRelay } = require('../server/relay/discord');
const { createDiscordGateway, DEFAULT_URL, INTENTS } = require('../server/relay/discord-gateway');
const { createDiscordGateway, DEFAULT_URL, INTENTS } = require('../server/relay/discord-gateway');
@@ -43,7 +43,7 @@ function scriptedGateway() {
@@ -43,7 +43,7 @@ function scriptedGateway() {
gw.dispatch = (t, d) => gw.push(gw.last(), { op: 0, t, s: ++gw.seq, d });
gw.dispatch = (t, d) => gw.push(gw.last(), { op: 0, t, s: ++gw.seq, d });
return gw;
return gw;
}
}
-const waitFor = async (fn, what, ms = 2000) => { const end = Date.now() + ms; while (Date.now() < end) { if (fn()) return; await sleep(5); } assert.fail(`timed out waiting for ${what}`); };
+const waitFor = async (fn, what, ms = 2000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return; await sleep(5); } assert.fail(`timed out waiting for ${what}`); };
const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op === op);
const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op === op);
(async () => {
(async () => {
@@ -74,6 +74,7 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
@@ -74,6 +74,7 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
gw.dispatch('MESSAGE_CREATE', { id: '1' });
gw.dispatch('MESSAGE_CREATE', { id: '1' });
gw.dispatch('GUILD_CREATE', { id: '2' }); // not a message event: dropped here
gw.dispatch('GUILD_CREATE', { id: '2' }); // not a message event: dropped here
gw.dispatch('MESSAGE_DELETE', { id: '3' });
gw.dispatch('MESSAGE_DELETE', { id: '3' });
+ await new Promise((r) => setImmediate(r)); // handling is async: events queue in order
assert.deepStrictEqual(seen, [['MESSAGE_CREATE', '1'], ['MESSAGE_DELETE', '3']]);
assert.deepStrictEqual(seen, [['MESSAGE_CREATE', '1'], ['MESSAGE_DELETE', '3']]);
const st = g.status();
const st = g.status();
assert.deepStrictEqual([st.state, st.session, st.bot_user_id, st.events_handled], ['ready', true, BOT, 2]);
assert.deepStrictEqual([st.state, st.session, st.bot_user_id, st.events_handled], ['ready', true, BOT, 2]);
@@ -148,10 +149,10 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
@@ -148,10 +149,10 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
// ── inbound: what becomes a post ────────────────────────
// ── inbound: what becomes a post ────────────────────────
const hook = await startWebhooks();
const hook = await startWebhooks();
- const db = openDb(':memory:');
- const general = forumStore.getSpace(db, 'general');
+ const db = await testDb();
+ const general = await forumStore.getSpace(db, 'general');
const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` }, enabled: true });
const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env: { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general` }, enabled: true });
- relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true });
+ await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true });
const CHANNEL = '2000000000000000001'; // the fake webhook 1's channel (learned on the first send)
const CHANNEL = '2000000000000000001'; // the fake webhook 1's channel (learned on the first send)
const inbound = createDiscordInbound({ db, perMinute: 3, maxChars: 200 });
const inbound = createDiscordInbound({ db, perMinute: 3, maxChars: 200 });
const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });
const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });
@@ -160,38 +161,38 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
@@ -160,38 +161,38 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
const settle = async () => { await new Promise((r) => setImmediate(r)); await relay.drain(); await relay.drain(); };
const settle = async () => { await new Promise((r) => setImmediate(r)); await relay.drain(); await relay.drain(); };
const { thread } = await forum.createThread(samV, 'general', { title: 'Talk to Discord', body: 'Say hi over there' });
const { thread } = await forum.createThread(samV, 'general', { title: 'Talk to Discord', body: 'Say hi over there' });
await settle();
await settle();
- const threadMsg = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(thread.id).external_message_id;
- const failures = () => db.prepare('SELECT * FROM relay_inbound_failures ORDER BY id').all();
+ const threadMsg = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(thread.id)).external_message_id;
+ const failures = async () => await db.prepare('SELECT * FROM relay_inbound_failures ORDER BY id').all();
const msg = (extra = {}) => ({ id: nextId(), channel_id: CHANNEL, guild_id: '6000000000000000001', type: 19, content: 'hello from Discord', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: threadMsg, channel_id: CHANNEL }, mentions: [], attachments: [], ...extra });
const msg = (extra = {}) => ({ id: nextId(), channel_id: CHANNEL, guild_id: '6000000000000000001', type: 19, content: 'hello from Discord', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: threadMsg, channel_id: CHANNEL }, mentions: [], attachments: [], ...extra });
- const postsOf = (tid) => db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id').all(tid);
+ const postsOf = async (tid) => await db.prepare('SELECT * FROM posts WHERE thread_id = ? ORDER BY id').all(tid);
await check('a Discord reply to a relayed message becomes a post: origin discord, nobody on the site, the Discord name, mapped', async () => {
await check('a Discord reply to a relayed message becomes a post: origin discord, nobody on the site, the Discord name, mapped', async () => {
const m = msg({ content: 'Hi <@4000000000000000003> and <@&4000000000000000009> in <#4000000000000000010> @everyone <:wave:4000000000000000011> <t:1790000000:R>', mentions: [{ id: LEE, username: 'lee', global_name: 'Lee' }], member: { nick: 'Kim (mod)' } });
const m = msg({ content: 'Hi <@4000000000000000003> and <@&4000000000000000009> in <#4000000000000000010> @everyone <:wave:4000000000000000011> <t:1790000000:R>', mentions: [{ id: LEE, username: 'lee', global_name: 'Lee' }], member: { nick: 'Kim (mod)' } });
- const r = inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });
+ const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });
assert.strictEqual(r.status, 'applied', JSON.stringify(r));
assert.strictEqual(r.status, 'applied', JSON.stringify(r));
- const p = forumStore.getPost(db, r.post_id);
+ const p = await forumStore.getPost(db, r.post_id);
assert.deepStrictEqual([p.thread_id, p.origin, p.author_subject, p.relay_author], [thread.id, 'discord', null, 'Kim (mod)']);
assert.deepStrictEqual([p.thread_id, p.origin, p.author_subject, p.relay_author], [thread.id, 'discord', null, 'Kim (mod)']);
assert.strictEqual(p.body_markdown, 'Hi @Lee and @role in #channel @\u200beveryone :wave: 2026-09-21 14:13 UTC');
assert.strictEqual(p.body_markdown, 'Hi @Lee and @role in #channel @\u200beveryone :wave: 2026-09-21 14:13 UTC');
- const row = db.prepare('SELECT * FROM relay_message_map WHERE external_message_id = ?').get(m.id);
+ const row = await db.prepare('SELECT * FROM relay_message_map WHERE external_message_id = ?').get(m.id);
assert.deepStrictEqual([row.direction, row.local_type, row.local_id, row.thread_id, row.external_channel_id], ['in', 'post', p.id, thread.id, CHANNEL]);
assert.deepStrictEqual([row.direction, row.local_type, row.local_id, row.thread_id, row.external_channel_id], ['in', 'post', p.id, thread.id, CHANNEL]);
const shown = (await forum.getThread(samV, 'general', thread.slug, {})).posts.find((x) => x.id === p.id);
const shown = (await forum.getThread(samV, 'general', thread.slug, {})).posts.find((x) => x.id === p.id);
assert.deepStrictEqual([shown.author.display_name, shown.author.is_relay, shown.author.subject, shown.origin], ['Kim (mod)', true, null, 'discord']);
assert.deepStrictEqual([shown.author.display_name, shown.author.is_relay, shown.author.subject, shown.origin], ['Kim (mod)', true, null, 'discord']);
// The same message again (a gateway replay) is not taken twice.
// The same message again (a gateway replay) is not taken twice.
- assert.deepStrictEqual(inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT }), { status: 'ignored', reason: 'already relayed' });
+ assert.deepStrictEqual(await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT }), { status: 'ignored', reason: 'already relayed' });
});
});
await check('a message in a Discord thread started from a relayed message becomes a post in that thread', async () => {
await check('a message in a Discord thread started from a relayed message becomes a post in that thread', async () => {
- const r = inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, type: 0, message_reference: undefined, content: 'in the Discord thread' }));
+ const r = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, type: 0, message_reference: undefined, content: 'in the Discord thread' }));
assert.strictEqual(r.status, 'applied');
assert.strictEqual(r.status, 'applied');
- assert.strictEqual(forumStore.getPost(db, r.post_id).thread_id, thread.id);
+ assert.strictEqual((await forumStore.getPost(db, r.post_id)).thread_id, thread.id);
// A reply to that Discord-origin post (mapped 'in') lands in the same thread too.
// A reply to that Discord-origin post (mapped 'in') lands in the same thread too.
- const inId = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(r.post_id).external_message_id;
- const r2 = inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, message_reference: { message_id: inId }, content: 'reply to a reply', author: { id: LEE, username: 'lee' } }));
- assert.strictEqual(forumStore.getPost(db, r2.post_id).relay_author, 'lee');
+ const inId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(r.post_id)).external_message_id;
+ const r2 = await inbound.handle('MESSAGE_CREATE', msg({ channel_id: threadMsg, message_reference: { message_id: inId }, content: 'reply to a reply', author: { id: LEE, username: 'lee' } }));
+ assert.strictEqual((await forumStore.getPost(db, r2.post_id)).relay_author, 'lee');
});
});
await check('everything else is ignored: webhooks (the relay\'s own), bots, the bot itself, system messages, non-replies, other channels, inbound off', async () => {
await check('everything else is ignored: webhooks (the relay\'s own), bots, the bot itself, system messages, non-replies, other channels, inbound off', async () => {
- const n = postsOf(thread.id).length;
+ const n = (await postsOf(thread.id)).length;
const cases = [
const cases = [
[msg({ webhook_id: '1' }), /webhook message/],
[msg({ webhook_id: '1' }), /webhook message/],
[msg({ author: { id: LEE, username: 'lee', bot: true } }), /bot or system/],
[msg({ author: { id: LEE, username: 'lee', bot: true } }), /bot or system/],
@@ -203,33 +204,33 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
@@ -203,33 +204,33 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
[msg({ type: 0, content: '', message_reference: { type: 1, message_id: threadMsg, channel_id: CHANNEL } }), /not a reply to a relayed message/], // a forward
[msg({ type: 0, content: '', message_reference: { type: 1, message_id: threadMsg, channel_id: CHANNEL } }), /not a reply to a relayed message/], // a forward
];
];
for (const [m, re] of cases) {
for (const [m, re] of cases) {
- const r = inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });
+ const r = await inbound.handle('MESSAGE_CREATE', m, { botUserId: BOT });
assert.strictEqual(r.status, 'ignored', JSON.stringify(m));
assert.strictEqual(r.status, 'ignored', JSON.stringify(m));
assert.match(r.reason, re);
assert.match(r.reason, re);
}
}
- relay.updateMapping(1, { inbound: false });
- assert.match(inbound.handle('MESSAGE_CREATE', msg()).reason, /inbound is off/);
- relay.updateMapping(1, { inbound: true, discord_channel_id: '7000000000000000002' });
- assert.match(inbound.handle('MESSAGE_CREATE', msg()).reason, /not a mapped channel/);
- relay.updateMapping(1, { discord_channel_id: CHANNEL });
- assert.strictEqual(postsOf(thread.id).length, n);
- assert.strictEqual(failures().length, 0, 'ignoring is not a failure');
+ await relay.updateMapping(1, { inbound: false });
+ assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /inbound is off/);
+ await relay.updateMapping(1, { inbound: true, discord_channel_id: '7000000000000000002' });
+ assert.match((await inbound.handle('MESSAGE_CREATE', msg())).reason, /not a mapped channel/);
+ await relay.updateMapping(1, { discord_channel_id: CHANNEL });
+ assert.strictEqual((await postsOf(thread.id)).length, n);
+ assert.strictEqual((await failures()).length, 0, 'ignoring is not a failure');
});
});
await check('limits: a long message is cut, a fourth message a minute is refused, an empty one explains the intent; attachments are noted', async () => {
await check('limits: a long message is cut, a fourth message a minute is refused, an empty one explains the intent; attachments are noted', async () => {
- const long = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000020', username: 'long' }, content: 'x'.repeat(500) }));
- const body = forumStore.getPost(db, long.post_id).body_markdown;
+ const long = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000020', username: 'long' }, content: 'x'.repeat(500) }));
+ const body = (await forumStore.getPost(db, long.post_id)).body_markdown;
assert.strictEqual(body.length, 200);
assert.strictEqual(body.length, 200);
assert.ok(body.endsWith('…'));
assert.ok(body.endsWith('…'));
const talker = { id: '4000000000000000021', username: 'talker' };
const talker = { id: '4000000000000000021', username: 'talker' };
- for (let i = 0; i < 3; i++) assert.strictEqual(inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: `m${i}` })).status, 'applied');
- const r = inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: 'one too many' }));
+ for (let i = 0; i < 3; i++) assert.strictEqual((await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: `m${i}` }))).status, 'applied');
+ const r = await inbound.handle('MESSAGE_CREATE', msg({ author: talker, content: 'one too many' }));
assert.deepStrictEqual([r.status, r.error], ['failed', 'rate limited']);
assert.deepStrictEqual([r.status, r.error], ['failed', 'rate limited']);
- const empty = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000022', username: 'quiet' }, content: '' }));
+ const empty = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000022', username: 'quiet' }, content: '' }));
assert.match(empty.error, /MESSAGE CONTENT intent/);
assert.match(empty.error, /MESSAGE CONTENT intent/);
- const pic = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000023', username: 'pics' }, content: '', attachments: [{ id: '1' }, { id: '2' }] }));
- assert.strictEqual(forumStore.getPost(db, pic.post_id).body_markdown, '_(2 attachments on Discord)_');
- const f = failures();
+ const pic = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000023', username: 'pics' }, content: '', attachments: [{ id: '1' }, { id: '2' }] }));
+ assert.strictEqual((await forumStore.getPost(db, pic.post_id)).body_markdown, '_(2 attachments on Discord)_');
+ const f = await failures();
assert.deepStrictEqual(f.map((x) => x.error.slice(0, 12)), ['rate limited', 'the message ']);
assert.deepStrictEqual(f.map((x) => x.error.slice(0, 12)), ['rate limited', 'the message ']);
assert.deepStrictEqual([f[0].event, f[0].external_author_id, f[0].mapping_id], ['MESSAGE_CREATE', talker.id, 1]);
assert.deepStrictEqual([f[0].event, f[0].external_author_id, f[0].mapping_id], ['MESSAGE_CREATE', talker.id, 1]);
assert.ok(!JSON.stringify(f).includes('one too many'), 'failures never keep the text');
assert.ok(!JSON.stringify(f).includes('one too many'), 'failures never keep the text');
@@ -239,50 +240,50 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
@@ -239,50 +240,50 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
await check('closed threads refuse replies from Discord and say why: locked, members-only, deleted', async () => {
await check('closed threads refuse replies from Discord and say why: locked, members-only, deleted', async () => {
const t = (await forum.createThread(samV, 'general', { title: 'Soon closed', body: 'x' })).thread;
const t = (await forum.createThread(samV, 'general', { title: 'Soon closed', body: 'x' })).thread;
await settle();
await settle();
- const anchor = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(t.id).external_message_id;
+ const anchor = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_type = 'thread' AND local_id = ?").get(t.id)).external_message_id;
const to = (extra = {}) => msg({ author: { id: '4000000000000000030', username: 'late' }, message_reference: { message_id: anchor }, ...extra });
const to = (extra = {}) => msg({ author: { id: '4000000000000000030', username: 'late' }, message_reference: { message_id: anchor }, ...extra });
- forumStore.setThreadFlags(db, t.id, { locked: true });
- assert.strictEqual(inbound.handle('MESSAGE_CREATE', to()).error, 'the thread is locked');
- forumStore.setThreadFlags(db, t.id, { locked: false });
- forumStore.setThreadMembersOnly(db, t.id, sam);
- assert.strictEqual(inbound.handle('MESSAGE_CREATE', to()).error, 'the thread is not public');
- forumStore.setThreadMembersOnly(db, t.id, null);
- forumStore.softDeleteThread(db, t.id);
- assert.strictEqual(inbound.handle('MESSAGE_CREATE', to()).error, 'the thread was deleted');
- assert.strictEqual(postsOf(t.id).length, 1, 'only the opening post');
+ await forumStore.setThreadFlags(db, t.id, { locked: true });
+ assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is locked');
+ await forumStore.setThreadFlags(db, t.id, { locked: false });
+ await forumStore.setThreadMembersOnly(db, t.id, sam);
+ assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread is not public');
+ await forumStore.setThreadMembersOnly(db, t.id, null);
+ await forumStore.softDeleteThread(db, t.id);
+ assert.strictEqual((await inbound.handle('MESSAGE_CREATE', to())).error, 'the thread was deleted');
+ assert.strictEqual((await postsOf(t.id)).length, 1, 'only the opening post');
});
});
await check('edits and deletes on Discord follow through the map; a delete of the relay\'s own message leaves the thread', async () => {
await check('edits and deletes on Discord follow through the map; a delete of the relay\'s own message leaves the thread', async () => {
const m = msg({ author: { id: '4000000000000000040', username: 'editor' }, content: 'first words' });
const m = msg({ author: { id: '4000000000000000040', username: 'editor' }, content: 'first words' });
- const { post_id: pid } = inbound.handle('MESSAGE_CREATE', m);
- const r = inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, content: 'second words <@4000000000000000003>', mentions: [{ id: LEE, username: 'lee' }], edited_timestamp: new Date().toISOString() });
+ const { post_id: pid } = await inbound.handle('MESSAGE_CREATE', m);
+ const r = await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, content: 'second words <@4000000000000000003>', mentions: [{ id: LEE, username: 'lee' }], edited_timestamp: new Date().toISOString() });
assert.strictEqual(r.status, 'applied');
assert.strictEqual(r.status, 'applied');
- const p = forumStore.getPost(db, pid);
+ const p = await forumStore.getPost(db, pid);
assert.deepStrictEqual([p.body_markdown, p.revision], ['second words @lee', 2]);
assert.deepStrictEqual([p.body_markdown, p.revision], ['second words @lee', 2]);
- assert.deepStrictEqual(forumStore.listPostVersions(db, pid).map((v) => v.edited_by), [null, 'discord']);
- assert.match(inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, embeds: [] }).reason, /no text change/);
- assert.match(inbound.handle('MESSAGE_UPDATE', { id: threadMsg, channel_id: CHANNEL, content: 'edited on Discord?' }).reason, /relay's own/);
- const other = inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000041', username: 'bulk' }, content: 'bulk me' }));
- const otherId = db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(other.post_id).external_message_id;
- assert.strictEqual(inbound.handle('MESSAGE_DELETE', { id: m.id, channel_id: CHANNEL }).status, 'applied');
- assert.ok(forumStore.getPost(db, pid).deleted_at, 'the post is deleted');
- const bulk = inbound.handle('MESSAGE_DELETE_BULK', { ids: [otherId, '1234567890123456789'], channel_id: CHANNEL });
+ assert.deepStrictEqual((await forumStore.listPostVersions(db, pid)).map((v) => v.edited_by), [null, 'discord']);
+ assert.match((await inbound.handle('MESSAGE_UPDATE', { id: m.id, channel_id: CHANNEL, embeds: [] })).reason, /no text change/);
+ assert.match((await inbound.handle('MESSAGE_UPDATE', { id: threadMsg, channel_id: CHANNEL, content: 'edited on Discord?' })).reason, /relay's own/);
+ const other = await inbound.handle('MESSAGE_CREATE', msg({ author: { id: '4000000000000000041', username: 'bulk' }, content: 'bulk me' }));
+ const otherId = (await db.prepare("SELECT external_message_id FROM relay_message_map WHERE local_id = ? AND direction = 'in'").get(other.post_id)).external_message_id;
+ assert.strictEqual((await inbound.handle('MESSAGE_DELETE', { id: m.id, channel_id: CHANNEL })).status, 'applied');
+ assert.ok((await forumStore.getPost(db, pid)).deleted_at, 'the post is deleted');
+ const bulk = await inbound.handle('MESSAGE_DELETE_BULK', { ids: [otherId, '1234567890123456789'], channel_id: CHANNEL });
assert.deepStrictEqual(bulk.map((x) => x.status), ['applied', 'ignored']);
assert.deepStrictEqual(bulk.map((x) => x.status), ['applied', 'ignored']);
- assert.ok(forumStore.getPost(db, other.post_id).deleted_at);
+ assert.ok((await forumStore.getPost(db, other.post_id)).deleted_at);
// A moderator on Discord removed the relay's announcement: the map knows, the thread stays, no edit goes there again.
// A moderator on Discord removed the relay's announcement: the map knows, the thread stays, no edit goes there again.
- assert.match(inbound.handle('MESSAGE_DELETE', { id: threadMsg, channel_id: CHANNEL }).note, /thread stays/);
- assert.ok(forumStore.getThread(db, thread.id), 'the thread is still here');
- assert.ok(db.prepare('SELECT external_deleted_at FROM relay_message_map WHERE external_message_id = ?').get(threadMsg).external_deleted_at);
- assert.strictEqual(relay.enqueueEdit(forumStore.getPost(db, db.prepare('SELECT id FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id).id)), 0);
+ assert.match((await inbound.handle('MESSAGE_DELETE', { id: threadMsg, channel_id: CHANNEL })).note, /thread stays/);
+ assert.ok(await forumStore.getThread(db, thread.id), 'the thread is still here');
+ assert.ok((await db.prepare('SELECT external_deleted_at FROM relay_message_map WHERE external_message_id = ?').get(threadMsg)).external_deleted_at);
+ assert.strictEqual(await relay.enqueueEdit(await forumStore.getPost(db, (await db.prepare('SELECT id FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id)).id)), 0);
});
});
await check('loop prevention: nothing from Discord is ever relayed back out', async () => {
await check('loop prevention: nothing from Discord is ever relayed back out', async () => {
await settle();
await settle();
const before = hook.hits.length;
const before = hook.hits.length;
- const fromDiscord = postsOf(thread.id).filter((p) => p.origin === 'discord');
+ const fromDiscord = (await postsOf(thread.id)).filter((p) => p.origin === 'discord');
assert.ok(fromDiscord.length >= 5);
assert.ok(fromDiscord.length >= 5);
- for (const p of fromDiscord) assert.strictEqual(relay.enqueuePost(p, forumStore.getThread(db, thread.id), general), 0);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM relay_deliveries WHERE post_id IN (SELECT id FROM posts WHERE origin = \'discord\')').get().n, 0);
+ for (const p of fromDiscord) assert.strictEqual(await relay.enqueuePost(p, await forumStore.getThread(db, thread.id), general), 0);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM relay_deliveries WHERE post_id IN (SELECT id FROM posts WHERE origin = \'discord\')').get()).n, 0);
await settle();
await settle();
assert.strictEqual(hook.hits.length, before, 'no webhook call for anything that came from Discord');
assert.strictEqual(hook.hits.length, before, 'no webhook call for anything that came from Discord');
});
});
@@ -303,19 +304,20 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
@@ -303,19 +304,20 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });
const samJwt = net.sign({ id: 9, subject_id: samU.subject_id, username: 'sam', role: 'user' });
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
const call = (p, { method = 'GET', cookie, json } = {}) => t.get(p, { method, headers: json !== undefined ? { 'content-type': 'application/json' } : {}, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
- await waitFor(() => t.app.locals.relay.status().inbound.state === 'ready', 'the app\'s gateway READY');
+ await waitFor(async () => (await t.app.locals.relay.status()).inbound.state === 'ready', 'the app\'s gateway READY');
assert.strictEqual(sent(gw, 2)[0].d.token, 'app-bot-token-not-real');
assert.strictEqual(sent(gw, 2)[0].d.token, 'app-bot-token-not-real');
const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true } });
const m = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL', inbound: true } });
assert.strictEqual(m.json().mapping.inbound, true);
assert.strictEqual(m.json().mapping.inbound, true);
const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Ask Discord', body: 'what do you think?' } });
const th = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Ask Discord', body: 'what do you think?' } });
- await waitFor(() => t.db.prepare("SELECT 1 FROM relay_message_map WHERE local_type = 'thread'").get(), 'the thread on Discord');
- const anchor = t.db.prepare("SELECT external_message_id, external_channel_id FROM relay_message_map WHERE local_type = 'thread'").get();
+ await waitFor(async () => await t.db.prepare("SELECT 1 FROM relay_message_map WHERE local_type = 'thread'").get(), 'the thread on Discord');
+ const anchor = await t.db.prepare("SELECT external_message_id, external_channel_id FROM relay_message_map WHERE local_type = 'thread'").get();
gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });
gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });
const locked = nextId();
const locked = nextId();
+ await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');
const page = await call(`/s/general/t/${th.json().thread.slug}`);
const page = await call(`/s/general/t/${th.json().thread.slug}`);
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');
assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');
- t.db.prepare('UPDATE threads SET locked = 1').run();
+ await t.db.prepare('UPDATE threads SET locked = 1').run();
gw.dispatch('MESSAGE_CREATE', { id: locked, channel_id: anchor.external_channel_id, type: 19, content: 'too late', author: { id: KIM, username: 'kim' }, message_reference: { message_id: anchor.external_message_id } });
gw.dispatch('MESSAGE_CREATE', { id: locked, channel_id: anchor.external_channel_id, type: 19, content: 'too late', author: { id: KIM, username: 'kim' }, message_reference: { message_id: anchor.external_message_id } });
assert.strictEqual((await call('/api/v1/relay/inbound', { cookie: samJwt })).status, 403);
assert.strictEqual((await call('/api/v1/relay/inbound', { cookie: samJwt })).status, 403);
const inb = await call('/api/v1/relay/inbound', { cookie: adminJwt });
const inb = await call('/api/v1/relay/inbound', { cookie: adminJwt });
@@ -18,7 +18,7 @@ const path = require('path');
@@ -18,7 +18,7 @@ const path = require('path');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { startWebhooks } = require('./helpers/fake-discord');
const { startWebhooks } = require('./helpers/fake-discord');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const forumStore = require('../server/forum/store');
const forumStore = require('../server/forum/store');
const { createDiscordRelay } = require('../server/relay/discord');
const { createDiscordRelay } = require('../server/relay/discord');
const { createForumService } = require('../server/forum/service');
const { createForumService } = require('../server/forum/service');
@@ -26,39 +26,39 @@ const { sqlTime } = require('../server/http/v1');
@@ -26,39 +26,39 @@ const { sqlTime } = require('../server/http/v1');
(async () => {
(async () => {
const hook = await startWebhooks();
const hook = await startWebhooks();
- const db = openDb(':memory:');
- const general = forumStore.getSpace(db, 'general');
- const feedback = forumStore.getSpace(db, 'feedback');
+ const db = await testDb();
+ const general = await forumStore.getSpace(db, 'general');
+ const feedback = await forumStore.getSpace(db, 'feedback');
const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general`, DISCORD_WEBHOOK_FEEDBACK: `${hook.url}/api/webhooks/2/feedback` };
const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/general`, DISCORD_WEBHOOK_FEEDBACK: `${hook.url}/api/webhooks/2/feedback` };
let clock = Date.parse('2026-09-22T12:00:00Z');
let clock = Date.parse('2026-09-22T12:00:00Z');
const now = () => clock;
const now = () => clock;
const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env, enabled: true, baseMs: 60_000, maxAttempts: 3, now });
const relay = createDiscordRelay({ db, config: { baseUrl: 'https://openvibe.community' }, env, enabled: true, baseMs: 60_000, maxAttempts: 3, now });
const alex = ids.newId('user');
const alex = ids.newId('user');
- require('../server/pastes/store').upsertProjection(db, { subject_id: alex, username: 'alex', display_name: 'Alex @everyone' });
- relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
- relay.addMapping({ space_id: feedback.id, webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' });
- const newThread = (space, title, extra = {}) => forumStore.createThread(db, { space_id: space.id, title, author_subject: alex, body_markdown: '**Hello** there, [link](https://x.y)', ...extra }).thread;
- const deliveries = () => db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();
- const mapOf = (type, id) => db.prepare('SELECT * FROM relay_message_map WHERE local_type = ? AND local_id = ? ORDER BY id').all(type, id);
+ await require('../server/pastes/store').upsertProjection(db, { subject_id: alex, username: 'alex', display_name: 'Alex @everyone' });
+ await relay.addMapping({ space_id: general.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
+ await relay.addMapping({ space_id: feedback.id, webhook_url_ref: 'DISCORD_WEBHOOK_FEEDBACK' });
+ const newThread = async (space, title, extra = {}) => (await forumStore.createThread(db, { space_id: space.id, title, author_subject: alex, body_markdown: '**Hello** there, [link](https://x.y)', ...extra })).thread;
+ const deliveries = async () => await db.prepare('SELECT * FROM relay_deliveries ORDER BY id').all();
+ const mapOf = async (type, id) => await db.prepare('SELECT * FROM relay_message_map WHERE local_type = ? AND local_id = ? ORDER BY id').all(type, id);
const settle = () => new Promise((r) => setImmediate(r));
const settle = () => new Promise((r) => setImmediate(r));
const EMPTY = { delivered: 0, retry: 0, failed: 0, skipped: 0 };
const EMPTY = { delivered: 0, retry: 0, failed: 0, skipped: 0 };
await check('off by default: nothing is queued or sent', async () => {
await check('off by default: nothing is queued or sent', async () => {
const off = createDiscordRelay({ db, env, now });
const off = createDiscordRelay({ db, env, now });
assert.strictEqual(off.enabled, false);
assert.strictEqual(off.enabled, false);
- const th = newThread(general, 'Quiet thread');
- assert.strictEqual(off.enqueueThread(th, general), 0);
- assert.strictEqual(off.enqueuePost(forumStore.addPost(db, { thread_id: th.id, author_subject: alex, body_markdown: 'shh' }), th, general), 0);
+ const th = await newThread(general, 'Quiet thread');
+ assert.strictEqual(await off.enqueueThread(th, general), 0);
+ assert.strictEqual(await off.enqueuePost(await forumStore.addPost(db, { thread_id: th.id, author_subject: alex, body_markdown: 'shh' }), th, general), 0);
assert.deepStrictEqual(await off.drain(), EMPTY);
assert.deepStrictEqual(await off.drain(), EMPTY);
- assert.strictEqual(deliveries().length, 0);
- const st = off.status();
+ assert.strictEqual((await deliveries()).length, 0);
+ const st = await off.status();
assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled], [false, false, false]);
assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled], [false, false, false]);
});
});
await check('a new thread is posted once with ?wait=true, attribution, a link and no mentions; its message id is mapped', async () => {
await check('a new thread is posted once with ?wait=true, attribution, a link and no mentions; its message id is mapped', async () => {
- const th = newThread(general, 'Relay me <please>');
- assert.strictEqual(relay.enqueueThread(th, general), 1);
- assert.strictEqual(relay.enqueueThread(th, general), 0, 'dedupe key per (thread, mapping)');
+ const th = await newThread(general, 'Relay me <please>');
+ assert.strictEqual(await relay.enqueueThread(th, general), 1);
+ assert.strictEqual(await relay.enqueueThread(th, general), 0, 'dedupe key per (thread, mapping)');
await settle();
await settle();
await relay.drain();
await relay.drain();
assert.strictEqual(hook.hits.length, 1);
assert.strictEqual(hook.hits.length, 1);
@@ -71,40 +71,40 @@ const { sqlTime } = require('../server/http/v1');
@@ -71,40 +71,40 @@ const { sqlTime } = require('../server/http/v1');
assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/s/general/t/relay-me-please');
assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/s/general/t/relay-me-please');
assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');
assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');
assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · s/general');
assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · s/general');
- const d = deliveries().find((x) => x.thread_id === th.id);
+ const d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);
assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);
- const [row] = mapOf('thread', th.id);
+ const [row] = await mapOf('thread', th.id);
assert.deepStrictEqual([row.platform, row.direction, row.mapping_id, row.thread_id, row.external_channel_id, row.external_thread_id, row.external_webhook_id],
assert.deepStrictEqual([row.platform, row.direction, row.mapping_id, row.thread_id, row.external_channel_id, row.external_thread_id, row.external_webhook_id],
['discord', 'out', 1, th.id, '2000000000000000001', null, '1']);
['discord', 'out', 1, th.id, '2000000000000000001', null, '1']);
assert.ok(hook.messages.has(row.external_message_id));
assert.ok(hook.messages.has(row.external_message_id));
- assert.strictEqual(db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 1').get().discord_channel_id, '2000000000000000001', 'the mapping learned its channel');
+ assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 1').get()).discord_channel_id, '2000000000000000001', 'the mapping learned its channel');
await relay.drain();
await relay.drain();
assert.strictEqual(hook.hits.length, 1, 'a delivered thread is never sent again');
assert.strictEqual(hook.hits.length, 1, 'a delivered thread is never sent again');
// Even a create queued again under another key is not sent twice: the map already has it.
// Even a create queued again under another key is not sent twice: the map already has it.
- db.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, 'replay-of-the-same', ?)").run(th.id, sqlTime(clock));
+ await db.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, 'replay-of-the-same', ?)").run(th.id, sqlTime(clock));
await relay.drain();
await relay.drain();
assert.strictEqual(hook.hits.length, 1);
assert.strictEqual(hook.hits.length, 1);
- assert.deepStrictEqual([deliveries().pop().status, deliveries().pop().last_error], ['delivered', 'already on Discord']);
+ assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['delivered', 'already on Discord']);
});
});
await check('the external message map is unique both ways', async () => {
await check('the external message map is unique both ways', async () => {
- const th = newThread(general, 'Unique map');
- const put = (mapping, type, local, external) => db.prepare(`INSERT INTO relay_message_map (mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)
+ const th = await newThread(general, 'Unique map');
+ const put = async (mapping, type, local, external) => await db.prepare(`INSERT INTO relay_message_map (mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id)
VALUES (?, 'out', ?, ?, ?, '2000000000000000001', ?)`).run(mapping, type, local, th.id, external);
VALUES (?, 'out', ?, ?, ?, '2000000000000000001', ?)`).run(mapping, type, local, th.id, external);
- put(1, 'thread', th.id, '1900000000000000001');
- assert.throws(() => put(2, 'post', 999, '1900000000000000001'), /UNIQUE/, 'one Discord message is one local object');
- assert.throws(() => put(1, 'thread', th.id, '1900000000000000002'), /UNIQUE/, 'one local object is one message per mapping');
- put(2, 'thread', th.id, '1900000000000000003'); // the same thread through another mapping is fine
- db.prepare('DELETE FROM relay_message_map WHERE thread_id = ?').run(th.id);
+ await put(1, 'thread', th.id, '1900000000000000001');
+ await assert.rejects(async () => await put(2, 'post', 999, '1900000000000000001'), /duplicate key/, 'one Discord message is one local object');
+ await assert.rejects(async () => await put(1, 'thread', th.id, '1900000000000000002'), /duplicate key/, 'one local object is one message per mapping');
+ await put(2, 'thread', th.id, '1900000000000000003'); // the same thread through another mapping is fine
+ await db.prepare('DELETE FROM relay_message_map WHERE thread_id = ?').run(th.id);
});
});
await check('retries with exponential backoff on 5xx, then delivers', async () => {
await check('retries with exponential backoff on 5xx, then delivers', async () => {
hook.hits.length = 0;
hook.hits.length = 0;
hook.plan.push({ status: 502, body: { message: 'bad gateway' } });
hook.plan.push({ status: 502, body: { message: 'bad gateway' } });
- const th = newThread(feedback, 'Flaky Discord');
- relay.enqueueThread(th, feedback);
+ const th = await newThread(feedback, 'Flaky Discord');
+ await relay.enqueueThread(th, feedback);
await settle(); await relay.drain();
await settle(); await relay.drain();
- let d = deliveries().find((x) => x.thread_id === th.id);
+ let d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 1, 502]);
assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 1, 502]);
assert.match(d.last_error, /Discord answered 502: bad gateway/);
assert.match(d.last_error, /Discord answered 502: bad gateway/);
assert.strictEqual(d.next_attempt_at, '2026-09-22 12:01:00', 'first retry after baseMs');
assert.strictEqual(d.next_attempt_at, '2026-09-22 12:01:00', 'first retry after baseMs');
@@ -113,114 +113,114 @@ const { sqlTime } = require('../server/http/v1');
@@ -113,114 +113,114 @@ const { sqlTime } = require('../server/http/v1');
clock += 60_000;
clock += 60_000;
hook.plan.push({ status: 500 });
hook.plan.push({ status: 500 });
await relay.drain();
await relay.drain();
- d = deliveries().find((x) => x.thread_id === th.id);
+ d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.next_attempt_at], ['pending', 2, '2026-09-22 12:03:00'], 'second retry after 2 × baseMs');
assert.deepStrictEqual([d.status, d.attempts, d.next_attempt_at], ['pending', 2, '2026-09-22 12:03:00'], 'second retry after 2 × baseMs');
clock += 120_000;
clock += 120_000;
await relay.drain();
await relay.drain();
- d = deliveries().find((x) => x.thread_id === th.id);
+ d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.last_error], ['delivered', 3, null]);
assert.deepStrictEqual([d.status, d.attempts, d.last_error], ['delivered', 3, null]);
assert.strictEqual(hook.hits.length, 3);
assert.strictEqual(hook.hits.length, 3);
});
});
await check('429 honours retry_after and holds that webhook for the pass; used-up attempts end as failed; other 4xx fail at once', async () => {
await check('429 honours retry_after and holds that webhook for the pass; used-up attempts end as failed; other 4xx fail at once', async () => {
- const th = newThread(general, 'Rate limited');
- const th2 = newThread(general, 'Behind it');
+ const th = await newThread(general, 'Rate limited');
+ const th2 = await newThread(general, 'Behind it');
hook.plan.push({ status: 429, body: { message: 'You are being rate limited.', retry_after: 300 } });
hook.plan.push({ status: 429, body: { message: 'You are being rate limited.', retry_after: 300 } });
- relay.enqueueThread(th, general);
- relay.enqueueThread(th2, general);
+ await relay.enqueueThread(th, general);
+ await relay.enqueueThread(th2, general);
const before = hook.hits.length;
const before = hook.hits.length;
await settle(); await relay.drain();
await settle(); await relay.drain();
assert.strictEqual(hook.hits.length, before + 1, 'the next delivery to the same webhook waits for the next pass');
assert.strictEqual(hook.hits.length, before + 1, 'the next delivery to the same webhook waits for the next pass');
- let d = deliveries().find((x) => x.thread_id === th.id);
+ let d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.strictEqual(d.status, 'pending');
assert.strictEqual(d.status, 'pending');
assert.strictEqual(Date.parse(`${d.next_attempt_at.replace(' ', 'T')}Z`) - clock, 300_000);
assert.strictEqual(Date.parse(`${d.next_attempt_at.replace(' ', 'T')}Z`) - clock, 300_000);
await relay.drain();
await relay.drain();
- assert.strictEqual(deliveries().find((x) => x.thread_id === th2.id).status, 'delivered');
+ assert.strictEqual((await deliveries()).find((x) => x.thread_id === th2.id).status, 'delivered');
clock += 300_000;
clock += 300_000;
hook.plan.push({ status: 503 }, { status: 503 });
hook.plan.push({ status: 503 }, { status: 503 });
await relay.drain();
await relay.drain();
clock += 10 * 60_000;
clock += 10 * 60_000;
await relay.drain();
await relay.drain();
- d = deliveries().find((x) => x.thread_id === th.id);
+ d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['failed', 3, 503]);
assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['failed', 3, 503]);
- const gone = newThread(general, 'Webhook deleted');
+ const gone = await newThread(general, 'Webhook deleted');
hook.plan.push({ status: 404, body: { message: 'Unknown Webhook', code: 10015 } });
hook.plan.push({ status: 404, body: { message: 'Unknown Webhook', code: 10015 } });
- relay.enqueueThread(gone, general);
+ await relay.enqueueThread(gone, general);
await settle(); await relay.drain();
await settle(); await relay.drain();
- d = deliveries().find((x) => x.thread_id === gone.id);
+ d = (await deliveries()).find((x) => x.thread_id === gone.id);
assert.deepStrictEqual([d.status, d.attempts], ['failed', 1]);
assert.deepStrictEqual([d.status, d.attempts], ['failed', 1]);
assert.match(d.last_error, /404: Unknown Webhook/);
assert.match(d.last_error, /404: Unknown Webhook/);
});
});
await check('a missing webhook variable and network errors are retried, then it delivers; the URL never enters the DB', async () => {
await check('a missing webhook variable and network errors are retried, then it delivers; the URL never enters the DB', async () => {
- const showcase = forumStore.getSpace(db, 'showcase');
- relay.addMapping({ space_id: showcase.id, webhook_url_ref: 'DISCORD_WEBHOOK_SHOWCASE' });
- const th = newThread(showcase, 'Env later');
- relay.enqueueThread(th, showcase);
+ const showcase = await forumStore.getSpace(db, 'showcase');
+ await relay.addMapping({ space_id: showcase.id, webhook_url_ref: 'DISCORD_WEBHOOK_SHOWCASE' });
+ const th = await newThread(showcase, 'Env later');
+ await relay.enqueueThread(th, showcase);
await settle(); await relay.drain();
await settle(); await relay.drain();
- let d = deliveries().find((x) => x.thread_id === th.id);
+ let d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.last_error], ['pending', 'webhook URL variable DISCORD_WEBHOOK_SHOWCASE is not set']);
assert.deepStrictEqual([d.status, d.last_error], ['pending', 'webhook URL variable DISCORD_WEBHOOK_SHOWCASE is not set']);
env.DISCORD_WEBHOOK_SHOWCASE = 'http://127.0.0.1:1/api/webhooks/refused';
env.DISCORD_WEBHOOK_SHOWCASE = 'http://127.0.0.1:1/api/webhooks/refused';
clock += 60_000;
clock += 60_000;
await relay.drain();
await relay.drain();
- d = deliveries().find((x) => x.thread_id === th.id);
+ d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 2, null], 'a network error is retried');
assert.deepStrictEqual([d.status, d.attempts, d.last_status], ['pending', 2, null], 'a network error is retried');
assert.ok(d.last_error);
assert.ok(d.last_error);
env.DISCORD_WEBHOOK_SHOWCASE = `${hook.url}/api/webhooks/3/showcase`;
env.DISCORD_WEBHOOK_SHOWCASE = `${hook.url}/api/webhooks/3/showcase`;
clock += 120_000;
clock += 120_000;
await relay.drain();
await relay.drain();
- d = deliveries().find((x) => x.thread_id === th.id);
+ d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.strictEqual(d.status, 'delivered');
assert.strictEqual(d.status, 'delivered');
- const everything = JSON.stringify(db.prepare('SELECT * FROM relay_mappings').all()) + JSON.stringify(deliveries()) + JSON.stringify(db.prepare('SELECT * FROM relay_message_map').all());
+ const everything = JSON.stringify(await db.prepare('SELECT * FROM relay_mappings').all()) + JSON.stringify(await deliveries()) + JSON.stringify(await db.prepare('SELECT * FROM relay_message_map').all());
assert.ok(!everything.includes('/api/webhooks/'), 'no webhook URL stored anywhere');
assert.ok(!everything.includes('/api/webhooks/'), 'no webhook URL stored anywhere');
});
});
await check('loop prevention: Discord-origin threads and posts are never relayed; members/staff spaces never leave the site', async () => {
await check('loop prevention: Discord-origin threads and posts are never relayed; members/staff spaces never leave the site', async () => {
const before = hook.hits.length;
const before = hook.hits.length;
- const fromDiscord = newThread(general, 'Came from Discord', { origin: 'discord' });
- assert.strictEqual(relay.enqueueThread(fromDiscord, general), 0);
+ const fromDiscord = await newThread(general, 'Came from Discord', { origin: 'discord' });
+ assert.strictEqual(await relay.enqueueThread(fromDiscord, general), 0);
// Even a delivery row that got queued somehow is refused at send time.
// Even a delivery row that got queued somehow is refused at send time.
- db.prepare('INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, ?, ?)').run(fromDiscord.id, `thread:${fromDiscord.id}:mapping:1`, sqlTime(clock));
+ await db.prepare('INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, 1, ?, ?)').run(fromDiscord.id, `thread:${fromDiscord.id}:mapping:1`, sqlTime(clock));
await relay.drain();
await relay.drain();
- const d = deliveries().find((x) => x.thread_id === fromDiscord.id);
+ const d = (await deliveries()).find((x) => x.thread_id === fromDiscord.id);
assert.deepStrictEqual([d.status, d.last_error], ['failed', 'loop prevention: thread came from Discord']);
assert.deepStrictEqual([d.status, d.last_error], ['failed', 'loop prevention: thread came from Discord']);
// A reply that came from Discord, in a thread that is on Discord, stays here too.
// A reply that came from Discord, in a thread that is on Discord, stays here too.
- const th = newThread(general, 'Mixed replies');
- relay.enqueueThread(th, general);
+ const th = await newThread(general, 'Mixed replies');
+ await relay.enqueueThread(th, general);
await settle(); await relay.drain();
await settle(); await relay.drain();
- const inbound = forumStore.addPost(db, { thread_id: th.id, origin: 'discord', body_markdown: 'from discord', relay_author: 'Kim' });
- assert.strictEqual(relay.enqueuePost(inbound, th, general), 0);
- db.prepare('INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, 1, ?, ?)').run(th.id, inbound.id, `post:${inbound.id}:mapping:1`, sqlTime(clock));
+ const inbound = await forumStore.addPost(db, { thread_id: th.id, origin: 'discord', body_markdown: 'from discord', relay_author: 'Kim' });
+ assert.strictEqual(await relay.enqueuePost(inbound, th, general), 0);
+ await db.prepare('INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, 1, ?, ?)').run(th.id, inbound.id, `post:${inbound.id}:mapping:1`, sqlTime(clock));
await relay.drain();
await relay.drain();
- assert.deepStrictEqual([deliveries().pop().status, deliveries().pop().last_error], ['failed', 'loop prevention: post came from Discord']);
- db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
- const ins = forumStore.getSpace(db, 'insiders');
- relay.addMapping({ space_id: ins.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
- assert.strictEqual(relay.enqueueThread(newThread(ins, 'Members only'), ins), 0);
+ assert.deepStrictEqual([(await deliveries()).pop().status, (await deliveries()).pop().last_error], ['failed', 'loop prevention: post came from Discord']);
+ await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
+ const ins = await forumStore.getSpace(db, 'insiders');
+ await relay.addMapping({ space_id: ins.id, webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' });
+ assert.strictEqual(await relay.enqueueThread(await newThread(ins, 'Members only'), ins), 0);
assert.strictEqual(hook.hits.length, before + 1, 'only the public thread went out');
assert.strictEqual(hook.hits.length, before + 1, 'only the public thread went out');
});
});
await check('disabled mappings queue nothing; retry() puts a failed delivery back with a fresh budget; drop() keeps it as dropped', async () => {
await check('disabled mappings queue nothing; retry() puts a failed delivery back with a fresh budget; drop() keeps it as dropped', async () => {
- relay.setMappingEnabled(1, false);
- assert.strictEqual(relay.enqueueThread(newThread(general, 'Mapping off'), general), 0);
- relay.setMappingEnabled(1, true);
- const failed = deliveries().find((x) => x.status === 'failed' && x.last_status === 404);
- assert.strictEqual(relay.retry(failed.id), 1);
+ await relay.setMappingEnabled(1, false);
+ assert.strictEqual(await relay.enqueueThread(await newThread(general, 'Mapping off'), general), 0);
+ await relay.setMappingEnabled(1, true);
+ const failed = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 404);
+ assert.strictEqual(await relay.retry(failed.id), 1);
await settle(); await relay.drain();
await settle(); await relay.drain();
- assert.strictEqual(deliveries().find((x) => x.id === failed.id).status, 'delivered');
- assert.strictEqual(relay.retry(failed.id), 0, 'delivered ones stay delivered');
- const dead = deliveries().find((x) => x.status === 'failed' && x.last_status === 503);
- assert.strictEqual(relay.drop(dead.id), 1);
- assert.strictEqual(deliveries().find((x) => x.id === dead.id).status, 'dropped');
- assert.strictEqual(relay.drop(dead.id), 0);
- assert.strictEqual(relay.status().deliveries.dropped, 1);
+ assert.strictEqual((await deliveries()).find((x) => x.id === failed.id).status, 'delivered');
+ assert.strictEqual(await relay.retry(failed.id), 0, 'delivered ones stay delivered');
+ const dead = (await deliveries()).find((x) => x.status === 'failed' && x.last_status === 503);
+ assert.strictEqual(await relay.drop(dead.id), 1);
+ assert.strictEqual((await deliveries()).find((x) => x.id === dead.id).status, 'dropped');
+ assert.strictEqual(await relay.drop(dead.id), 0);
+ assert.strictEqual((await relay.status()).deliveries.dropped, 1);
});
});
// ── replies, edits and deletes through the forum service ─────────────────────────
// ── replies, edits and deletes through the forum service ─────────────────────────
const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });
const forum = createForumService({ db, relay, limits: { threads: { cooldownSec: 0, perMinute: 100 }, posts: { cooldownSec: 0, perMinute: 100 }, threadsPerDay: 0 } });
const sam = ids.newId('user');
const sam = ids.newId('user');
- require('../server/pastes/store').upsertProjection(db, { subject_id: sam, username: 'sam', display_name: 'Sam' });
+ await require('../server/pastes/store').upsertProjection(db, { subject_id: sam, username: 'sam', display_name: 'Sam' });
const samV = { kind: 'user', subject: sam, staff: false };
const samV = { kind: 'user', subject: sam, staff: false };
const alexV = { kind: 'user', subject: alex, staff: false };
const alexV = { kind: 'user', subject: alex, staff: false };
const modV = { kind: 'user', subject: ids.newId('user'), staff: true };
const modV = { kind: 'user', subject: ids.newId('user'), staff: true };
@@ -237,7 +237,7 @@ const { sqlTime } = require('../server/http/v1');
@@ -237,7 +237,7 @@ const { sqlTime } = require('../server/http/v1');
assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.community\/s\/general\/t\/replies-go-too#post-\d+>$/);
assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.community\/s\/general\/t\/replies-go-too#post-\d+>$/);
assert.strictEqual(r.body.embeds[0].description, 'First reply');
assert.strictEqual(r.body.embeds[0].description, 'First reply');
assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });
assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });
- const [row] = mapOf('post', post.id);
+ const [row] = await mapOf('post', post.id);
assert.deepStrictEqual([row.direction, row.thread_id], ['out', thread.id]);
assert.deepStrictEqual([row.direction, row.thread_id], ['out', thread.id]);
// The thread's own message failing for now: the reply waits, without using an attempt.
// The thread's own message failing for now: the reply waits, without using an attempt.
hook.hits.length = 0;
hook.hits.length = 0;
@@ -245,26 +245,26 @@ const { sqlTime } = require('../server/http/v1');
@@ -245,26 +245,26 @@ const { sqlTime } = require('../server/http/v1');
const t2 = await forum.createThread(samV, 'general', { title: 'Thread first', body: 'x' });
const t2 = await forum.createThread(samV, 'general', { title: 'Thread first', body: 'x' });
const p2 = (await forum.reply(alexV, 'general', t2.thread.slug, { body: 'waits' })).post;
const p2 = (await forum.reply(alexV, 'general', t2.thread.slug, { body: 'waits' })).post;
await drainAll();
await drainAll();
- const reply = deliveries().find((x) => x.post_id === p2.id);
+ const reply = (await deliveries()).find((x) => x.post_id === p2.id);
assert.deepStrictEqual([reply.status, reply.attempts, reply.last_error], ['pending', 0, "waiting for the thread's own message"]);
assert.deepStrictEqual([reply.status, reply.attempts, reply.last_error], ['pending', 0, "waiting for the thread's own message"]);
assert.strictEqual(hook.hits.length, 1, 'only the thread was tried');
assert.strictEqual(hook.hits.length, 1, 'only the thread was tried');
clock += 60_000;
clock += 60_000;
await drainAll();
await drainAll();
assert.deepStrictEqual(hook.hits.map((h) => h.body.content.startsWith('New thread') ? 'thread' : 'reply'), ['thread', 'thread', 'reply'], 'the thread, then its reply');
assert.deepStrictEqual(hook.hits.map((h) => h.body.content.startsWith('New thread') ? 'thread' : 'reply'), ['thread', 'thread', 'reply'], 'the thread, then its reply');
- assert.strictEqual(deliveries().find((x) => x.post_id === p2.id).status, 'delivered');
+ assert.strictEqual((await deliveries()).find((x) => x.post_id === p2.id).status, 'delivered');
// A thread that was never relayed (made before the mapping): its replies are not either.
// A thread that was never relayed (made before the mapping): its replies are not either.
- const old = newThread(general, 'Before the mapping');
- assert.strictEqual(relay.enqueuePost(forumStore.addPost(db, { thread_id: old.id, author_subject: sam, body_markdown: 'late' }), old, general), 0);
+ const old = await newThread(general, 'Before the mapping');
+ assert.strictEqual(await relay.enqueuePost(await forumStore.addPost(db, { thread_id: old.id, author_subject: sam, body_markdown: 'late' }), old, general), 0);
});
});
await check('a mapping that names a Discord thread posts into it (thread_id) and edits and deletes there', async () => {
await check('a mapping that names a Discord thread posts into it (thread_id) and edits and deletes there', async () => {
hook.hits.length = 0;
hook.hits.length = 0;
- relay.updateMapping(2, { discord_thread_id: '3000000000000000002' });
+ await relay.updateMapping(2, { discord_thread_id: '3000000000000000002' });
const { thread } = await forum.createThread(samV, 'feedback', { title: 'Into a Discord thread', body: 'hi' });
const { thread } = await forum.createThread(samV, 'feedback', { title: 'Into a Discord thread', body: 'hi' });
const { post } = await forum.reply(samV, 'feedback', thread.slug, { body: 'reply in thread' });
const { post } = await forum.reply(samV, 'feedback', thread.slug, { body: 'reply in thread' });
await drainAll();
await drainAll();
assert.deepStrictEqual(hook.hits.map((h) => h.path), ['/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002', '/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002'], JSON.stringify(hook.hits.map((h) => h.path)));
assert.deepStrictEqual(hook.hits.map((h) => h.path), ['/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002', '/api/webhooks/2/feedback?wait=true&thread_id=3000000000000000002'], JSON.stringify(hook.hits.map((h) => h.path)));
- const [row] = mapOf('post', post.id);
+ const [row] = await mapOf('post', post.id);
assert.deepStrictEqual([row.external_channel_id, row.external_thread_id], ['3000000000000000002', '3000000000000000002']);
assert.deepStrictEqual([row.external_channel_id, row.external_thread_id], ['3000000000000000002', '3000000000000000002']);
await forum.editPost(samV, post.id, { body: 'reply in thread, edited' });
await forum.editPost(samV, post.id, { body: 'reply in thread, edited' });
await drainAll();
await drainAll();
@@ -274,8 +274,8 @@ const { sqlTime } = require('../server/http/v1');
@@ -274,8 +274,8 @@ const { sqlTime } = require('../server/http/v1');
`PATCH /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,
`PATCH /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,
`DELETE /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,
`DELETE /api/webhooks/2/feedback/messages/${row.external_message_id}?thread_id=3000000000000000002`,
]);
]);
- assert.strictEqual(db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 2').get().discord_channel_id, '2000000000000000002', 'the channel learned before stays');
- relay.updateMapping(2, { discord_thread_id: null });
+ assert.strictEqual((await db.prepare('SELECT discord_channel_id FROM relay_mappings WHERE id = 2').get()).discord_channel_id, '2000000000000000002', 'the channel learned before stays');
+ await relay.updateMapping(2, { discord_thread_id: null });
});
});
await check('edits follow through the map: a reply\'s message and, for the opening post, the thread\'s message are PATCHed', async () => {
await check('edits follow through the map: a reply\'s message and, for the opening post, the thread\'s message are PATCHed', async () => {
@@ -283,8 +283,8 @@ const { sqlTime } = require('../server/http/v1');
@@ -283,8 +283,8 @@ const { sqlTime } = require('../server/http/v1');
const { thread, post: opening } = await forum.createThread(samV, 'general', { title: 'Edit me', body: 'Version one' });
const { thread, post: opening } = await forum.createThread(samV, 'general', { title: 'Edit me', body: 'Version one' });
const { post } = await forum.reply(samV, 'general', thread.slug, { body: 'Reply one' });
const { post } = await forum.reply(samV, 'general', thread.slug, { body: 'Reply one' });
await drainAll();
await drainAll();
- const threadMsg = mapOf('thread', thread.id)[0].external_message_id;
- const postMsg = mapOf('post', post.id)[0].external_message_id;
+ const threadMsg = (await mapOf('thread', thread.id))[0].external_message_id;
+ const postMsg = (await mapOf('post', post.id))[0].external_message_id;
await forum.editPost(samV, post.id, { body: 'Reply **two**' });
await forum.editPost(samV, post.id, { body: 'Reply **two**' });
await forum.editPost(samV, opening.id, { body: 'Version two' });
await forum.editPost(samV, opening.id, { body: 'Version two' });
await forum.editPost(samV, opening.id, { body: 'Version two' }); // no change: no revision, nothing queued
await forum.editPost(samV, opening.id, { body: 'Version two' }); // no change: no revision, nothing queued
@@ -295,18 +295,18 @@ const { sqlTime } = require('../server/http/v1');
@@ -295,18 +295,18 @@ const { sqlTime } = require('../server/http/v1');
assert.strictEqual(edits[1].body.embeds[0].description, 'Version two');
assert.strictEqual(edits[1].body.embeds[0].description, 'Version two');
assert.deepStrictEqual(edits[1].body.allowed_mentions, { parse: [] });
assert.deepStrictEqual(edits[1].body.allowed_mentions, { parse: [] });
assert.strictEqual(hook.messages.get(threadMsg).body.embeds[0].description, 'Version two');
assert.strictEqual(hook.messages.get(threadMsg).body.embeds[0].description, 'Version two');
- const keys = deliveries().filter((d) => d.action === 'edit').map((d) => d.dedupe_key);
+ const keys = (await deliveries()).filter((d) => d.action === 'edit').map((d) => d.dedupe_key);
assert.ok(keys.includes(`edit:post:${post.id}:mapping:1:r2`) && keys.includes(`edit:thread:${thread.id}:mapping:1:r2`), keys.join());
assert.ok(keys.includes(`edit:post:${post.id}:mapping:1:r2`) && keys.includes(`edit:thread:${thread.id}:mapping:1:r2`), keys.join());
// Someone deleted the reply's message on Discord: the next edit finds nothing and says so.
// Someone deleted the reply's message on Discord: the next edit finds nothing and says so.
hook.messages.get(postMsg).deleted = true;
hook.messages.get(postMsg).deleted = true;
await forum.editPost(samV, post.id, { body: 'Reply three' });
await forum.editPost(samV, post.id, { body: 'Reply three' });
await drainAll();
await drainAll();
- const last = deliveries().pop();
+ const last = (await deliveries()).pop();
assert.deepStrictEqual([last.action, last.status, last.last_error], ['edit', 'skipped', 'the message is gone on Discord']);
assert.deepStrictEqual([last.action, last.status, last.last_error], ['edit', 'skipped', 'the message is gone on Discord']);
- assert.ok(mapOf('post', post.id)[0].external_deleted_at, 'the map knows it is gone');
- const queued = deliveries().length;
+ assert.ok((await mapOf('post', post.id))[0].external_deleted_at, 'the map knows it is gone');
+ const queued = (await deliveries()).length;
await forum.editPost(samV, post.id, { body: 'Reply four' });
await forum.editPost(samV, post.id, { body: 'Reply four' });
- assert.strictEqual(deliveries().length, queued, 'nothing more is queued for a message that is gone');
+ assert.strictEqual((await deliveries()).length, queued, 'nothing more is queued for a message that is gone');
// An edit right before a delete is skipped: only the delete goes out.
// An edit right before a delete is skipped: only the delete goes out.
const { post: p2 } = await forum.reply(samV, 'general', thread.slug, { body: 'short-lived' });
const { post: p2 } = await forum.reply(samV, 'general', thread.slug, { body: 'short-lived' });
await drainAll();
await drainAll();
@@ -315,7 +315,7 @@ const { sqlTime } = require('../server/http/v1');
@@ -315,7 +315,7 @@ const { sqlTime } = require('../server/http/v1');
await forum.deletePost(samV, p2.id);
await forum.deletePost(samV, p2.id);
await drainAll();
await drainAll();
assert.deepStrictEqual(hook.hits.map((h) => h.method), ['DELETE']);
assert.deepStrictEqual(hook.hits.map((h) => h.method), ['DELETE']);
- assert.strictEqual(deliveries().find((d) => d.post_id === p2.id && d.action === 'edit').status, 'skipped');
+ assert.strictEqual((await deliveries()).find((d) => d.post_id === p2.id && d.action === 'edit').status, 'skipped');
});
});
await check('deletes follow through the map: a reply, and a thread with every reply of it; what was not sent yet is skipped', async () => {
await check('deletes follow through the map: a reply, and a thread with every reply of it; what was not sent yet is skipped', async () => {
@@ -324,7 +324,7 @@ const { sqlTime } = require('../server/http/v1');
@@ -324,7 +324,7 @@ const { sqlTime } = require('../server/http/v1');
const a = (await forum.reply(samV, 'general', thread.slug, { body: 'a' })).post;
const a = (await forum.reply(samV, 'general', thread.slug, { body: 'a' })).post;
const b = (await forum.reply(alexV, 'general', thread.slug, { body: 'b' })).post;
const b = (await forum.reply(alexV, 'general', thread.slug, { body: 'b' })).post;
await drainAll();
await drainAll();
- const msgs = { thread: mapOf('thread', thread.id)[0].external_message_id, a: mapOf('post', a.id)[0].external_message_id, b: mapOf('post', b.id)[0].external_message_id };
+ const msgs = { thread: (await mapOf('thread', thread.id))[0].external_message_id, a: (await mapOf('post', a.id))[0].external_message_id, b: (await mapOf('post', b.id))[0].external_message_id };
await forum.deletePost(modV, a.id); // moderated away
await forum.deletePost(modV, a.id); // moderated away
await drainAll();
await drainAll();
assert.deepStrictEqual(hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname), [`/api/webhooks/1/general/messages/${msgs.a}`]);
assert.deepStrictEqual(hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname), [`/api/webhooks/1/general/messages/${msgs.a}`]);
@@ -334,15 +334,15 @@ const { sqlTime } = require('../server/http/v1');
@@ -334,15 +334,15 @@ const { sqlTime } = require('../server/http/v1');
const deleted = hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname).sort();
const deleted = hook.hits.filter((h) => h.method === 'DELETE').map((h) => h.pathname).sort();
assert.deepStrictEqual(deleted, [msgs.thread, msgs.a, msgs.b].map((m) => `/api/webhooks/1/general/messages/${m}`).sort());
assert.deepStrictEqual(deleted, [msgs.thread, msgs.a, msgs.b].map((m) => `/api/webhooks/1/general/messages/${m}`).sort());
assert.ok(!hook.hits.some((h) => h.method === 'POST' && /c, never sent/.test(JSON.stringify(h.body))), 'the reply queued before the delete never went out');
assert.ok(!hook.hits.some((h) => h.method === 'POST' && /c, never sent/.test(JSON.stringify(h.body))), 'the reply queued before the delete never went out');
- assert.deepStrictEqual([deliveries().find((d) => d.post_id === c.id).status], ['skipped']);
- assert.ok(mapOf('thread', thread.id).every((r) => r.external_deleted_at));
+ assert.deepStrictEqual([(await deliveries()).find((d) => d.post_id === c.id).status], ['skipped']);
+ assert.ok((await mapOf('thread', thread.id)).every((r) => r.external_deleted_at));
// A delete whose message is already gone on Discord counts as done.
// A delete whose message is already gone on Discord counts as done.
const t3 = (await forum.createThread(samV, 'general', { title: 'Gone already', body: 'x' })).thread;
const t3 = (await forum.createThread(samV, 'general', { title: 'Gone already', body: 'x' })).thread;
await drainAll();
await drainAll();
- hook.messages.get(mapOf('thread', t3.id)[0].external_message_id).deleted = true;
+ hook.messages.get((await mapOf('thread', t3.id))[0].external_message_id).deleted = true;
await forum.deleteThread(samV, 'general', t3.slug);
await forum.deleteThread(samV, 'general', t3.slug);
await drainAll();
await drainAll();
- const del = deliveries().pop();
+ const del = (await deliveries()).pop();
assert.deepStrictEqual([del.action, del.status, del.last_status, del.last_error], ['delete', 'delivered', 404, 'the message was already gone on Discord']);
assert.deepStrictEqual([del.action, del.status, del.last_status, del.last_error], ['delete', 'delivered', 404, 'the message was already gone on Discord']);
});
});
@@ -357,51 +357,20 @@ const { sqlTime } = require('../server/http/v1');
@@ -357,51 +357,20 @@ const { sqlTime } = require('../server/http/v1');
await drainAll();
await drainAll();
assert.strictEqual(hook.hits.filter((h) => h.method === 'DELETE').length, 2, 'the thread\'s and the reply\'s messages were deleted');
assert.strictEqual(hook.hits.filter((h) => h.method === 'DELETE').length, 2, 'the thread\'s and the reply\'s messages were deleted');
// Replies in the gated thread (the creator may still post) never go out.
// Replies in the gated thread (the creator may still post) never go out.
- const gatedThread = db.prepare('SELECT * FROM threads WHERE id = ?').get(thread.id);
- const reply = forumStore.addPost(db, { thread_id: thread.id, author_subject: sam, body_markdown: 'members only' });
- assert.strictEqual(relay.enqueuePost(reply, gatedThread, general), 0);
+ const gatedThread = await db.prepare('SELECT * FROM threads WHERE id = ?').get(thread.id);
+ const reply = await forumStore.addPost(db, { thread_id: thread.id, author_subject: sam, body_markdown: 'members only' });
+ assert.strictEqual(await relay.enqueuePost(reply, gatedThread, general), 0);
// A members-only thread from the start is never queued, nor is anything in a VIP space.
// A members-only thread from the start is never queued, nor is anything in a VIP space.
- const gated = newThread(general, 'VIP from the start', { members_only_owner: sam });
- assert.strictEqual(relay.enqueueThread(gated, general), 0);
- db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(sam, feedback.id);
- const vipSpace = forumStore.getSpace(db, 'feedback');
- assert.strictEqual(relay.enqueueThread(newThread(vipSpace, 'In a VIP space'), vipSpace), 0);
- db.prepare('UPDATE spaces SET members_only_owner = NULL WHERE id = ?').run(feedback.id);
+ const gated = await newThread(general, 'VIP from the start', { members_only_owner: sam });
+ assert.strictEqual(await relay.enqueueThread(gated, general), 0);
+ await db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(sam, feedback.id);
+ const vipSpace = await forumStore.getSpace(db, 'feedback');
+ assert.strictEqual(await relay.enqueueThread(await newThread(vipSpace, 'In a VIP space'), vipSpace), 0);
+ await db.prepare('UPDATE spaces SET members_only_owner = NULL WHERE id = ?').run(feedback.id);
await drainAll();
await drainAll();
assert.ok(!hook.hits.slice(posted).some((h) => h.method !== 'DELETE'), 'nothing but the two deletes');
assert.ok(!hook.hits.slice(posted).some((h) => h.method !== 'DELETE'), 'nothing but the two deletes');
});
});
- await check('an older database: relay_deliveries is rebuilt once, rows kept, the new columns in place', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-relay-migrate-'));
- const file = path.join(dir, 'community.db');
- const old = openDb(file);
- const th = forumStore.createThread(old, { space_id: 1, title: 'Old row', body_markdown: 'x' }).thread;
- old.exec(`DROP TABLE relay_deliveries;
- CREATE TABLE relay_deliveries (id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
- mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE, dedupe_key TEXT UNIQUE NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')), attempts INTEGER NOT NULL DEFAULT 0,
- next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_status INTEGER, last_error TEXT, delivered_at DATETIME,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (thread_id, mapping_id));
- CREATE INDEX idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at);`);
- old.prepare("INSERT INTO relay_mappings (space_id, webhook_url_ref) VALUES (1, 'DISCORD_WEBHOOK_GENERAL')").run();
- old.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, status, attempts, last_status) VALUES (?, 1, ?, 'delivered', 1, 204)").run(th.id, `thread:${th.id}:mapping:1`);
- old.close();
- const reopened = openDb(file);
- const cols = reopened.prepare('PRAGMA table_info(relay_deliveries)').all().map((c) => c.name);
- for (const c of ['post_id', 'action', 'source', 'event_id']) assert.ok(cols.includes(c), c);
- const row = reopened.prepare('SELECT * FROM relay_deliveries').get();
- assert.deepStrictEqual([row.thread_id, row.dedupe_key, row.status, row.action, row.source, row.last_status], [th.id, `thread:${th.id}:mapping:1`, 'delivered', 'create', 'direct', 204]);
- reopened.prepare("INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, status) VALUES (?, 1, 'x', 'dropped')").run(th.id);
- assert.ok(reopened.prepare("SELECT 1 FROM sqlite_master WHERE name = 'idx_relay_deliveries_due'").get());
- const mcols = reopened.prepare('PRAGMA table_info(relay_mappings)').all().map((c) => c.name);
- assert.ok(['discord_channel_id', 'discord_thread_id', 'inbound'].every((c) => mcols.includes(c)));
- reopened.close();
- const again = openDb(file); // idempotent
- assert.strictEqual(again.prepare('SELECT COUNT(*) AS n FROM relay_deliveries').get().n, 2);
- again.close();
- fs.rmSync(dir, { recursive: true, force: true });
- });
-
await check('app wiring: threads, replies, edits and deletes through the API are relayed; the admin endpoints are staff-only and URL-free', async () => {
await check('app wiring: threads, replies, edits and deletes through the API are relayed; the admin endpoints are staff-only and URL-free', async () => {
const t = await boot({
const t = await boot({
authority: 'community',
authority: 'community',
@@ -418,8 +387,8 @@ const { sqlTime } = require('../server/http/v1');
@@ -418,8 +387,8 @@ const { sqlTime } = require('../server/http/v1');
return t.get(p, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
return t.get(p, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
};
};
const r = t.app.locals.relay;
const r = t.app.locals.relay;
- assert.strictEqual(r.status().creates_from, 'forum', 'no EVENTS_URL: the forum queues creates');
- assert.deepStrictEqual([r.status().events_worker.enabled, r.status().inbound.enabled], [false, false]);
+ assert.strictEqual((await r.status()).creates_from, 'forum', 'no EVENTS_URL: the forum queues creates');
+ assert.deepStrictEqual([(await r.status()).events_worker.enabled, (await r.status()).inbound.enabled], [false, false]);
assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: samJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 403);
assert.strictEqual((await call('/api/v1/relay/mappings', { method: 'POST', cookie: samJwt, json: { space: 'general', webhook_url_ref: 'DISCORD_WEBHOOK_GENERAL' } })).status, 403);
const bad = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'https://discord.com/api/webhooks/secret' } });
const bad = await call('/api/v1/relay/mappings', { method: 'POST', cookie: adminJwt, json: { space: 'general', webhook_url_ref: 'https://discord.com/api/webhooks/secret' } });
assert.strictEqual(bad.status, 400);
assert.strictEqual(bad.status, 400);
@@ -497,18 +466,18 @@ const { sqlTime } = require('../server/http/v1');
@@ -497,18 +466,18 @@ const { sqlTime } = require('../server/http/v1');
const svc = await map(ref, { token: net.signService({ cap: ['community.comment.moderate'] }) });
const svc = await map(ref, { token: net.signService({ cap: ['community.comment.moderate'] }) });
assert.strictEqual(svc.status, 400, `${ref} as a service`);
assert.strictEqual(svc.status, 400, `${ref} as a service`);
}
}
- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get().n, 0);
- assert.throws(() => t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);
+ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get()).n, 0);
+ await assert.rejects(async () => await t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);
// A mapping that predates the allow-list (straight into the table) is never sent to, and
// A mapping that predates the allow-list (straight into the table) is never sent to, and
// does not reveal whether that variable is set.
// does not reveal whether that variable is set.
- const gen = t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();
- t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(gen.id);
+ const gen = await t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();
+ await t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(gen.id);
const listed = (await call('/api/v1/relay/mappings', { cookie: adminJwt })).json().mappings.find((x) => x.webhook_url_ref === 'OV_MEDIA_INTERNAL_URL');
const listed = (await call('/api/v1/relay/mappings', { cookie: adminJwt })).json().mappings.find((x) => x.webhook_url_ref === 'OV_MEDIA_INTERNAL_URL');
assert.strictEqual(listed.webhook_configured, false);
assert.strictEqual(listed.webhook_configured, false);
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Would go inside', body: 'hi' } })).status, 201);
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Would go inside', body: 'hi' } })).status, 201);
await t.app.locals.relay.drain();
await t.app.locals.relay.drain();
assert.deepStrictEqual(secretHits, [], 'nothing was sent to the internal URL');
assert.deepStrictEqual(secretHits, [], 'nothing was sent to the internal URL');
- const d = t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();
+ const d = await t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();
assert.strictEqual(d.status, 'failed');
assert.strictEqual(d.status, 'failed');
assert.match(d.last_error, /not an allowed webhook variable/);
assert.match(d.last_error, /not an allowed webhook variable/);
assert.strictEqual((await map('DISCORD_WEBHOOK_GENERAL')).status, 201, 'the conventional names still work');
assert.strictEqual((await map('DISCORD_WEBHOOK_GENERAL')).status, 201, 'the conventional names still work');
@@ -526,7 +495,7 @@ const { sqlTime } = require('../server/http/v1');
@@ -526,7 +495,7 @@ const { sqlTime } = require('../server/http/v1');
process.env.EVENTS_URL = 'http://127.0.0.1:9';
process.env.EVENTS_URL = 'http://127.0.0.1:9';
try {
try {
const t = await boot({ authority: 'community' });
const t = await boot({ authority: 'community' });
- const st = t.app.locals.relay.status();
+ const st = await t.app.locals.relay.status();
assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled, t.app.locals.relayInbound], [false, false, false, null]);
assert.deepStrictEqual([st.enabled, st.events_worker.enabled, st.inbound.enabled, t.app.locals.relayInbound], [false, false, false, null]);
assert.match(st.events_worker.reason, /relay is off/);
assert.match(st.events_worker.reason, /relay is off/);
await t.close();
await t.close();
@@ -9,23 +9,26 @@ const assert = require('assert');
@@ -9,23 +9,26 @@ const assert = require('assert');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { createRevocationStore } = require('openvibe-sdk/auth');
-const { openDb } = require('../server/db');
+const { createPgRevocationStore } = require('openvibe-sdk/auth');
+const { testDb } = require('./helpers/db');
const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
const { createViewerResolver } = require('../server/identity/viewer');
const { createViewerResolver } = require('../server/identity/viewer');
-const SECRET = `whsec_${'ab'.repeat(32)}`;
-const db = openDb(':memory:');
-const revocations = createRevocationStore(db, { table: 'token_revocations' });
-const app = express();
-app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], revocations, log: { error() {}, log() {}, warn() {} } }).router);
-const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', BEN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
-const at = Date.now() - 5000;
-const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_type: 'network.user.token_valid_after', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
- subject: { type: 'user', id: ANN }, actor: { type: 'user', id: ANN },
- payload: { subject: { type: 'user', id: ANN }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' }, ...over });
+let SECRET, db, revocations, app, ANN, BEN, at, evt;
(async () => {
(async () => {
+ SECRET = `whsec_${'ab'.repeat(32)}`;
+ db = await testDb();
+ revocations = createPgRevocationStore(db, { table: 'token_revocations' });
+ await revocations.load();
+ app = express();
+ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], revocations, log: { error() {}, log() {}, warn() {} } }).router);
+ ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ BEN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+ at = Date.now() - 5000;
+ evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_type: 'network.user.token_valid_after', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
+ subject: { type: 'user', id: ANN }, actor: { type: 'user', id: ANN },
+ payload: { subject: { type: 'user', id: ANN }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' }, ...over });
assert.ok(TOPICS.includes('network.user.token_valid_after'), 'Community subscribes to it');
assert.ok(TOPICS.includes('network.user.token_valid_after'), 'Community subscribes to it');
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const post = (event) => {
const post = (event) => {
@@ -35,7 +38,7 @@ const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_
@@ -35,7 +38,7 @@ const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN01', event_
const claimsOf = (subject, iatMs) => ({ sub: 7, subject_id: subject, username: 'someone', role: 'user', iat: Math.floor(iatMs / 1000), exp: Math.floor(Date.now() / 1000) + 3600 });
const claimsOf = (subject, iatMs) => ({ sub: 7, subject_id: subject, username: 'someone', role: 'user', iat: Math.floor(iatMs / 1000), exp: Math.floor(Date.now() / 1000) + 3600 });
let current = null;
let current = null;
const viewers = createViewerResolver({ auth: { verify: async () => current }, config: { allowSandbox: false }, network: null, revocations });
const viewers = createViewerResolver({ auth: { verify: async () => current }, config: { allowSandbox: false }, network: null, revocations });
- const who = async (claims) => { current = claims; return viewers.resolve({ headers: { authorization: 'Bearer opaque' }, cookies: {} }); };
+ const who = async (claims) => { current = claims; return await viewers.resolve({ headers: { authorization: 'Bearer opaque' }, cookies: {} }); };
try {
try {
assert.strictEqual((await who(claimsOf(ANN, at - 60000))).kind, 'user', 'before the event');
assert.strictEqual((await who(claimsOf(ANN, at - 60000))).kind, 'user', 'before the event');
let r = await post(evt());
let r = await post(evt());
@@ -9,105 +9,107 @@
@@ -9,105 +9,107 @@
*/
*/
const assert = require('assert');
const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const events = require('../server/events');
const events = require('../server/events');
const pastes = require('../server/pastes/store');
const pastes = require('../server/pastes/store');
const forum = require('../server/forum/store');
const forum = require('../server/forum/store');
const { createSearchDocuments } = require('../server/search/documents');
const { createSearchDocuments } = require('../server/search/documents');
-const db = openDb(':memory:');
-events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });
-const index = () => db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => /^community\.index_document\./.test(e.event_type));
-const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };
-const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
-const docs = createSearchDocuments({ db });
-const base = require('../server/config').baseUrl;
+(async () => {
+ const db = await testDb();
+ events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });
+ const index = async () => (await db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : r.envelope)).filter((e) => /^community\.index_document\./.test(e.event_type));
+ const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };
+ const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const docs = createSearchDocuments({ db });
+ const base = require('../server/config').baseUrl;
-let n = 0;
-function check(name, fn) { fn(); n++; console.log(` ✓ ${name}`); }
+ let n = 0;
+ async function check(name, fn) { await fn(); n++; console.log(` ✓ ${name}`); }
-db.prepare("INSERT OR IGNORE INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff')").run();
-const general = db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get().id;
-const staff = db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get().id;
+ await db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff') ON CONFLICT DO NOTHING").run();
+ const general = (await db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get()).id;
+ const staff = (await db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get()).id;
-check('a public thread: its title, opening post and replies; unchanged means nothing sent', () => {
- const { thread } = forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: '**First** post with a [link](https://example.com)' });
- forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });
- assert.strictEqual(docs.publishThread(thread.id), 'sent');
- const ev = index()[0]; ok(ev);
- assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });
- assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');
- assert.strictEqual(ev.payload.canonical_url, `${base}/s/general/t/${thread.slug}`);
- assert.strictEqual(ev.payload.summary, 'First post with a link');
- assert.ok(ev.payload.body.includes('a reply body'));
- assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);
- assert.strictEqual(docs.publishThread(thread.id), 'unchanged');
-});
+ await check('a public thread: its title, opening post and replies; unchanged means nothing sent', async () => {
+ const { thread } = await forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: '**First** post with a [link](https://example.com)' });
+ await forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });
+ assert.strictEqual(await docs.publishThread(thread.id), 'sent');
+ const ev = (await index())[0]; ok(ev);
+ assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });
+ assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');
+ assert.strictEqual(ev.payload.canonical_url, `${base}/s/general/t/${thread.slug}`);
+ assert.strictEqual(ev.payload.summary, 'First post with a link');
+ assert.ok(ev.payload.body.includes('a reply body'));
+ assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);
+ assert.strictEqual(await docs.publishThread(thread.id), 'unchanged');
+ });
-check('a staff thread is never sent; members-only gets one tombstone', () => {
- const { thread: hidden } = forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden' });
- assert.strictEqual(docs.publishThread(hidden.id), 'unchanged');
- const t = db.prepare("SELECT id FROM threads WHERE title = 'Hello there'").get();
- forum.setThreadMembersOnly(db, t.id, USR);
- assert.strictEqual(docs.publishThread(t.id), 'tombstone');
- assert.strictEqual(docs.publishThread(t.id), 'unchanged', 'a tombstone is sent once');
- const ev = index().at(-1); ok(ev);
- assert.deepStrictEqual(ev.payload, { type: 'thread', id: String(t.id), revision: 2 });
- assert.ok(!index().some((e) => JSON.stringify(e).includes('hidden')), 'nothing of a staff thread');
-});
+ await check('a staff thread is never sent; members-only gets one tombstone', async () => {
+ const { thread: hidden } = await forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden' });
+ assert.strictEqual(await docs.publishThread(hidden.id), 'unchanged');
+ const t = await db.prepare("SELECT id FROM threads WHERE title = 'Hello there'").get();
+ await forum.setThreadMembersOnly(db, t.id, USR);
+ assert.strictEqual(await docs.publishThread(t.id), 'tombstone');
+ assert.strictEqual(await docs.publishThread(t.id), 'unchanged', 'a tombstone is sent once');
+ const ev = (await index()).at(-1); ok(ev);
+ assert.deepStrictEqual(ev.payload, { type: 'thread', id: String(t.id), revision: 2 });
+ assert.ok(!(await index()).some((e) => JSON.stringify(e).includes('hidden')), 'nothing of a staff thread');
+ });
-check('a crosspost is noindex', () => {
- const { thread } = forum.createThread(db, { space_id: general, title: 'Also here', author_subject: USR, body_markdown: 'see the original', crosspost_of: 1 });
- docs.publishThread(thread.id);
- const ev = index().at(-1); ok(ev);
- assert.deepStrictEqual(ev.payload.indexability, { decision: 'noindex', reasons: ['crosspost'] });
-});
+ await check('a crosspost is noindex', async () => {
+ const { thread } = await forum.createThread(db, { space_id: general, title: 'Also here', author_subject: USR, body_markdown: 'see the original', crosspost_of: 1 });
+ await docs.publishThread(thread.id);
+ const ev = (await index()).at(-1); ok(ev);
+ assert.deepStrictEqual(ev.payload.indexability, { decision: 'noindex', reasons: ['crosspost'] });
+ });
-check('pastes: public sent with its content, NSFW noindex, burn-after-read never, unlisted and deleted get a tombstone', () => {
- const p = pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'Quick sort', content: 'function sort(a) { return a; }', language: 'javascript', visibility: 'public' });
- const nsfw = pastes.insertPaste(db, { slug: 'red-owl-7', owner_subject: USR, origin: 'ai', type: 'paste', title: 'x', content: 'y', language: 'text', visibility: 'public', is_nsfw: 1 });
- const burn = pastes.insertPaste(db, { slug: 'blue-cat-9', owner_subject: USR, type: 'paste', title: 'secret', content: 'burns', visibility: 'public', burn_after_read: 1 });
- const row = (id) => db.prepare('SELECT * FROM pastes WHERE id = ?').get(id);
- assert.strictEqual(docs.publishPaste(row(p.id)), 'sent');
- assert.strictEqual(docs.publishPaste(row(nsfw.id)), 'sent');
- assert.strictEqual(docs.publishPaste(row(burn.id)), 'unchanged');
- const [a, b] = index().slice(-2); ok(a); ok(b);
- assert.strictEqual(a.payload.id, 'amber-fox-42'); assert.strictEqual(a.payload.canonical_url, `${base}/p/amber-fox-42`);
- assert.ok(a.payload.body.includes('function sort')); assert.strictEqual(a.payload.facets.syntax, 'javascript');
- assert.ok(!('language' in a.payload), 'a paste\'s syntax is not a human language');
- assert.deepStrictEqual(b.payload.indexability, { decision: 'noindex', reasons: ['sensitive'] });
- assert.strictEqual(b.payload.authorship, 'ai_generated');
- pastes.setVisibility(db, p.id, 'unlisted');
- assert.strictEqual(docs.publishPaste(row(p.id)), 'tombstone');
- ok(index().at(-1)); assert.deepStrictEqual(index().at(-1).payload, { type: 'paste', id: 'amber-fox-42', revision: 2 });
-});
+ await check('pastes: public sent with its content, NSFW noindex, burn-after-read never, unlisted and deleted get a tombstone', async () => {
+ const p = await pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'Quick sort', content: 'function sort(a) { return a; }', language: 'javascript', visibility: 'public' });
+ const nsfw = await pastes.insertPaste(db, { slug: 'red-owl-7', owner_subject: USR, origin: 'ai', type: 'paste', title: 'x', content: 'y', language: 'text', visibility: 'public', is_nsfw: 1 });
+ const burn = await pastes.insertPaste(db, { slug: 'blue-cat-9', owner_subject: USR, type: 'paste', title: 'secret', content: 'burns', visibility: 'public', burn_after_read: 1 });
+ const row = async (id) => await db.prepare('SELECT * FROM pastes WHERE id = ?').get(id);
+ assert.strictEqual(await docs.publishPaste(await row(p.id)), 'sent');
+ assert.strictEqual(await docs.publishPaste(await row(nsfw.id)), 'sent');
+ assert.strictEqual(await docs.publishPaste(await row(burn.id)), 'unchanged');
+ const [a, b] = (await index()).slice(-2); ok(a); ok(b);
+ assert.strictEqual(a.payload.id, 'amber-fox-42'); assert.strictEqual(a.payload.canonical_url, `${base}/p/amber-fox-42`);
+ assert.ok(a.payload.body.includes('function sort')); assert.strictEqual(a.payload.facets.syntax, 'javascript');
+ assert.ok(!('language' in a.payload), 'a paste\'s syntax is not a human language');
+ assert.deepStrictEqual(b.payload.indexability, { decision: 'noindex', reasons: ['sensitive'] });
+ assert.strictEqual(b.payload.authorship, 'ai_generated');
+ await pastes.setVisibility(db, p.id, 'unlisted');
+ assert.strictEqual(await docs.publishPaste(await row(p.id)), 'tombstone');
+ ok((await index()).at(-1)); assert.deepStrictEqual((await index()).at(-1).payload, { type: 'paste', id: 'amber-fox-42', revision: 2 });
+ });
-check('the scan finds a paste changed since the last scan; the refresh removes one that is gone', () => {
- const now = Date.now();
- docs.scan({ now });
- const before = index().length;
- const p = pastes.insertPaste(db, { slug: 'green-elk-3', owner_subject: USR, type: 'paste', title: 'Later', content: 'later body', visibility: 'public' });
- db.prepare("UPDATE pastes SET created_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(p.id);
- docs.scan({ now: now + 60_000 });
- assert.ok(index().slice(before).some((e) => e.payload.id === 'green-elk-3' && e.event_type === 'community.index_document.upserted'));
- db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
- docs.refresh();
- const last = index().at(-1); ok(last);
- assert.deepStrictEqual(last.payload, { type: 'paste', id: 'green-elk-3', revision: 2 });
-});
+ await check('the scan finds a paste changed since the last scan; the refresh removes one that is gone', async () => {
+ const now = Date.now();
+ await docs.scan({ now });
+ const before = (await index()).length;
+ const p = await pastes.insertPaste(db, { slug: 'green-elk-3', owner_subject: USR, type: 'paste', title: 'Later', content: 'later body', visibility: 'public' });
+ await db.prepare("UPDATE pastes SET created_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(p.id);
+ await docs.scan({ now: now + 60_000 });
+ assert.ok((await index()).slice(before).some((e) => e.payload.id === 'green-elk-3' && e.event_type === 'community.index_document.upserted'));
+ await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
+ await docs.refresh();
+ const last = (await index()).at(-1); ok(last);
+ assert.deepStrictEqual(last.payload, { type: 'paste', id: 'green-elk-3', revision: 2 });
+ });
-check('a slug starting with a dash is indexed as paste_<id> (a document id starts with a letter or digit)', () => {
- const p = pastes.insertPaste(db, { slug: '-wgXuYn0', owner_subject: USR, type: 'paste', title: 'Dash', content: 'dash body', visibility: 'public' });
- db.prepare("INSERT INTO search_doc_pushes (type, id, hash, revision) VALUES ('paste', '-wgXuYn0', 'x', 1)").run();
- assert.strictEqual(docs.publishPaste(db.prepare('SELECT * FROM pastes WHERE id = ?').get(p.id)), 'sent');
- const ev = index().at(-1); ok(ev);
- assert.strictEqual(ev.payload.id, `paste_${p.id}`); assert.strictEqual(ev.payload.canonical_url, `${base}/p/-wgXuYn0`);
- const r = contracts.validate('search.index-document@1', ev.payload); assert.ok(r.valid, JSON.stringify(r.errors));
- const before = index().length;
- docs.refresh();
- assert.ok(!db.prepare("SELECT 1 FROM search_doc_pushes WHERE id = '-wgXuYn0'").get(), 'the unacceptable id is forgotten');
- assert.ok(!index().slice(before).some((e) => e.payload.id === '-wgXuYn0'), 'and no tombstone is sent for it');
-});
+ await check('a slug starting with a dash is indexed as paste_<id> (a document id starts with a letter or digit)', async () => {
+ const p = await pastes.insertPaste(db, { slug: '-wgXuYn0', owner_subject: USR, type: 'paste', title: 'Dash', content: 'dash body', visibility: 'public' });
+ await db.prepare("INSERT INTO search_doc_pushes (type, id, hash, revision) VALUES ('paste', '-wgXuYn0', 'x', 1)").run();
+ assert.strictEqual(await docs.publishPaste(await db.prepare('SELECT * FROM pastes WHERE id = ?').get(p.id)), 'sent');
+ const ev = (await index()).at(-1); ok(ev);
+ assert.strictEqual(ev.payload.id, `paste_${p.id}`); assert.strictEqual(ev.payload.canonical_url, `${base}/p/-wgXuYn0`);
+ const r = contracts.validate('search.index-document@1', ev.payload); assert.ok(r.valid, JSON.stringify(r.errors));
+ const before = (await index()).length;
+ await docs.refresh();
+ assert.ok(!await db.prepare("SELECT 1 FROM search_doc_pushes WHERE id = '-wgXuYn0'").get(), 'the unacceptable id is forgotten');
+ assert.ok(!(await index()).slice(before).some((e) => e.payload.id === '-wgXuYn0'), 'and no tombstone is sent for it');
+ });
-events._reset();
-console.log(`search documents: ${n} checks passed`);
+ events._reset();
+ console.log(`search documents: ${n} checks passed`);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -69,26 +69,26 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
@@ -69,26 +69,26 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
const res = await fetch(t.base + path, { method, headers: h, body: payload, redirect: 'manual' });
const res = await fetch(t.base + path, { method, headers: h, body: payload, redirect: 'manual' });
return { status: res.status, text: await res.text(), json() { return JSON.parse(this.text); } };
return { status: res.status, text: await res.text(), json() { return JSON.parse(this.text); } };
}
}
- const snapshot = () => TABLES.map((tb) => `${tb}:${JSON.stringify(t.db.prepare(`SELECT * FROM ${tb} ORDER BY rowid`).all())}`).join('\n');
+ const snapshot = async () => (await Promise.all(TABLES.map(async (tb) => `${tb}:${JSON.stringify(await t.db.prepare(`SELECT * FROM ${tb} ORDER BY ${tb}::text`).all())}`))).join('\n');
const ok = (r, what) => { assert.ok(r.status >= 200 && r.status < 300, `${what}: ${r.status} ${r.text.slice(0, 300)}`); return r.status === 204 ? null : r.json(); };
const ok = (r, what) => { assert.ok(r.status >= 200 && r.status < 300, `${what}: ${r.status} ${r.text.slice(0, 300)}`); return r.status === 204 ? null : r.json(); };
const imageForm = (field, extra = {}) => { const f = new FormData(); for (const [k, v] of Object.entries(extra)) f.append(k, v); f.append(field, new Blob([PNG], { type: 'image/png' }), 'x.png'); return f; };
const imageForm = (field, extra = {}) => { const f = new FormData(); for (const [k, v] of Object.entries(extra)) f.append(k, v); f.append(field, new Blob([PNG], { type: 'image/png' }), 'x.png'); return f; };
/** Each persona's attempt is refused (401/403/404, or the route's documented refusal) and changes nothing. */
/** Each persona's attempt is refused (401/403/404, or the route's documented refusal) and changes nothing. */
async function refused(label, method, path, personas, opts = {}, statuses = [401, 403, 404]) {
async function refused(label, method, path, personas, opts = {}, statuses = [401, 403, 404]) {
for (const name of personas) {
for (const name of personas) {
- const before = snapshot();
+ const before = await snapshot();
const o = typeof opts === 'function' ? opts() : opts;
const o = typeof opts === 'function' ? opts() : opts;
const r = await send(method, path, { ...as[name], ...o, headers: { ...(as[name].headers || {}), ...(o.headers || {}) } });
const r = await send(method, path, { ...as[name], ...o, headers: { ...(as[name].headers || {}), ...(o.headers || {}) } });
assert.ok(statuses.includes(r.status), `${label} as ${name}: ${r.status} ${r.text.slice(0, 200)}`);
assert.ok(statuses.includes(r.status), `${label} as ${name}: ${r.status} ${r.text.slice(0, 200)}`);
- assert.strictEqual(snapshot(), before, `${label} as ${name}: refused (${r.status}) but the database changed`);
+ assert.strictEqual(await snapshot(), before, `${label} as ${name}: refused (${r.status}) but the database changed`);
}
}
}
}
/** The owner (or staff) may: the route works, and it changes the database. */
/** The owner (or staff) may: the route works, and it changes the database. */
async function allowed(label, method, path, name, opts = {}) {
async function allowed(label, method, path, name, opts = {}) {
- const before = snapshot();
+ const before = await snapshot();
const r = await send(method, path, { ...as[name], ...opts, headers: { ...(as[name].headers || {}), ...(opts.headers || {}) } });
const r = await send(method, path, { ...as[name], ...opts, headers: { ...(as[name].headers || {}), ...(opts.headers || {}) } });
const out = ok(r, `${label} as ${name}`);
const out = ok(r, `${label} as ${name}`);
- assert.notStrictEqual(snapshot(), before, `${label} as ${name}: answered ${r.status} but nothing changed`);
+ assert.notStrictEqual(await snapshot(), before, `${label} as ${name}: answered ${r.status} but nothing changed`);
return out;
return out;
}
}
@@ -104,8 +104,8 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
@@ -104,8 +104,8 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
const cthread = ok(await send('POST', '/api/v1/comments/threads/resolve', { who: alex, json: { ref: { service: 'community', type: 'paste', id: pub.slug } } }), 'comment thread').thread;
const cthread = ok(await send('POST', '/api/v1/comments/threads/resolve', { who: alex, json: { ref: { service: 'community', type: 'paste', id: pub.slug } } }), 'comment thread').thread;
const typed = ok(await send('POST', `/api/v1/comments/threads/${cthread.id}/comments`, { who: alex, json: { message: 'alex typed comment' } }), 'typed comment').comment;
const typed = ok(await send('POST', `/api/v1/comments/threads/${cthread.id}/comments`, { who: alex, json: { message: 'alex typed comment' } }), 'typed comment').comment;
const upload = ok(await send('POST', '/api/v1/spaces/general/attachments', { who: alex, body: imageForm('file') }), 'alex upload').attachment;
const upload = ok(await send('POST', '/api/v1/spaces/general/attachments', { who: alex, body: imageForm('file') }), 'alex upload').attachment;
- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(alex.subject_id);
- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(cora.subject_id);
+ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(alex.subject_id);
+ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(cora.subject_id);
ok(await send('PUT', '/api/v1/spaces/general/chat-room', { who: alex, json: { room: 'alex-room' } }), 'alex attaches a room to his space');
ok(await send('PUT', '/api/v1/spaces/general/chat-room', { who: alex, json: { room: 'alex-room' } }), 'alex attaches a room to his space');
const liveItem = ok(await send('POST', '/api/v1/pulse/items', { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }), json: { ref: { service: 'live', type: 'stream', id: '9' }, title: 'alex is live', url: 'https://openvibe.live/@alex' } }), 'live pulse item');
const liveItem = ok(await send('POST', '/api/v1/pulse/items', { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }), json: { ref: { service: 'live', type: 'stream', id: '9' }, title: 'alex is live', url: 'https://openvibe.live/@alex' } }), 'live pulse item');
assert.ok(liveItem.item);
assert.ok(liveItem.item);
@@ -146,7 +146,7 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
@@ -146,7 +146,7 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
await check('likes and votes are always the caller\'s own; an app cannot name alex', async () => {
await check('likes and votes are always the caller\'s own; an app cannot name alex', async () => {
await allowed('sam likes alex\'s paste', 'POST', `/api/pastes/${pub.slug}/like`, 'sam');
await allowed('sam likes alex\'s paste', 'POST', `/api/pastes/${pub.slug}/like`, 'sam');
- const likers = t.db.prepare('SELECT subject_id FROM paste_likes pl JOIN pastes p ON p.id = pl.paste_id WHERE p.slug = ?').all(pub.slug).map((r) => r.subject_id);
+ const likers = (await t.db.prepare('SELECT subject_id FROM paste_likes pl JOIN pastes p ON p.id = pl.paste_id WHERE p.slug = ?').all(pub.slug)).map((r) => r.subject_id);
assert.deepStrictEqual(likers, [sam.subject_id]);
assert.deepStrictEqual(likers, [sam.subject_id]);
await refused('an app for sam likes as alex', 'POST', `/api/pastes/${pub.slug}/like`, ['an app for sam'], { headers: { 'x-ov-subject': alex.subject_id } });
await refused('an app for sam likes as alex', 'POST', `/api/pastes/${pub.slug}/like`, ['an app for sam'], { headers: { 'x-ov-subject': alex.subject_id } });
await refused('an app for sam votes as alex', 'POST', `/api/v1/spaces/general/threads/${thread.thread.slug}/votes`, ['an app for sam'], { headers: { 'x-ov-subject': alex.subject_id }, json: { value: 1 } });
await refused('an app for sam votes as alex', 'POST', `/api/v1/spaces/general/threads/${thread.thread.slug}/votes`, ['an app for sam'], { headers: { 'x-ov-subject': alex.subject_id }, json: { value: 1 } });
@@ -132,13 +132,13 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
@@ -132,13 +132,13 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
}
}
}
}
// Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.
// Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.
- const before = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;
+ const before = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;
for (const [method, suffix, json] of [['POST', '/like'], ['POST', '/copy'], ['POST', '/fork', {}], ['POST', '/comments', { message: 'hi there' }], ['DELETE', `/comments/${privComment.comment.id}`]]) {
for (const [method, suffix, json] of [['POST', '/like'], ['POST', '/copy'], ['POST', '/fork', {}], ['POST', '/comments', { message: 'hi there' }], ['DELETE', `/comments/${privComment.comment.id}`]]) {
const a = await send(method, `/api/pastes/${priv.slug}${suffix}`, { ...p, json }), b = await send(method, `/api/pastes/${UNKNOWN_SLUG}${suffix}`, { ...p, json });
const a = await send(method, `/api/pastes/${priv.slug}${suffix}`, { ...p, json }), b = await send(method, `/api/pastes/${UNKNOWN_SLUG}${suffix}`, { ...p, json });
assert.strictEqual(a.status, b.status, `${method} ${suffix} as ${name}: ${a.status} vs ${b.status}`);
assert.strictEqual(a.status, b.status, `${method} ${suffix} as ${name}: ${a.status} vs ${b.status}`);
assert.ok(!allMarkers.some((m) => a.text.includes(m)), `${method} ${suffix} as ${name} shows a marker`);
assert.ok(!allMarkers.some((m) => a.text.includes(m)), `${method} ${suffix} as ${name} shows a marker`);
}
}
- const after = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;
+ const after = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;
assert.strictEqual(after, before, `a refused write changed something (${name})`);
assert.strictEqual(after, before, `a refused write changed something (${name})`);
// A browser cannot open a comment thread on it either (it looks missing).
// A browser cannot open a comment thread on it either (it looks missing).
if (p.who) {
if (p.who) {
@@ -204,7 +204,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
@@ -204,7 +204,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
});
});
await check('events, Pulse and search documents: nothing private is public; comment events are public only for items Community knows are public', async () => {
await check('events, Pulse and search documents: nothing private is public; comment events are public only for items Community knows are public', async () => {
- const outbox = t.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
+ const outbox = (await t.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope) : r.envelope) : r.envelope));
const text = JSON.stringify(outbox);
const text = JSON.stringify(outbox);
for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!text.includes(m), `an event carries ${m}`);
for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!text.includes(m), `an event carries ${m}`);
const pub = (e) => e.visibility === 'public';
const pub = (e) => e.visibility === 'public';
@@ -232,7 +232,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
@@ -232,7 +232,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
const docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));
const docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));
assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');
assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');
for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);
for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);
- const pulse = JSON.stringify(t.db.prepare('SELECT * FROM pulse_items').all());
+ const pulse = JSON.stringify(await t.db.prepare('SELECT * FROM pulse_items').all());
assert.ok(pulse.includes('Open thread'), 'Pulse took the public thread');
assert.ok(pulse.includes('Open thread'), 'Pulse took the public thread');
for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!pulse.includes(m), `Pulse holds ${m}`);
for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!pulse.includes(m), `Pulse holds ${m}`);
});
});
@@ -143,7 +143,7 @@ function formsOf(value) {
@@ -143,7 +143,7 @@ function formsOf(value) {
assert.strictEqual(r.status, 200, `a delivery signed with ${secret === EVENTS_SECRETS[0] ? 'the current' : 'the previous'} secret: ${r.text}`);
assert.strictEqual(r.status, 200, `a delivery signed with ${secret === EVENTS_SECRETS[0] ? 'the current' : 'the previous'} secret: ${r.text}`);
}
}
const visitor = `ip:${crypto.createHmac('sha256', ENV.VIEW_HASH_SECRET).update('203.0.113.9').digest('hex').slice(0, 32)}`;
const visitor = `ip:${crypto.createHmac('sha256', ENV.VIEW_HASH_SECRET).update('203.0.113.9').digest('hex').slice(0, 32)}`;
- assert.ok(t.db.prepare('SELECT 1 FROM paste_visits WHERE visitor = ?').get(visitor), 'anonymous views are keyed by an HMAC under VIEW_HASH_SECRET');
+ assert.ok(await t.db.prepare('SELECT 1 FROM paste_visits WHERE visitor = ?').get(visitor), 'anonymous views are keyed by an HMAC under VIEW_HASH_SECRET');
});
});
// What must never come back.
// What must never come back.
@@ -268,12 +268,12 @@ function formsOf(value) {
@@ -268,12 +268,12 @@ function formsOf(value) {
});
});
await check('the events outbox, the relay\'s recorded errors and the service\'s own tables hold no secret', async () => {
await check('the events outbox, the relay\'s recorded errors and the service\'s own tables hold no secret', async () => {
- const outbox = t.db.prepare('SELECT envelope FROM event_outbox').all();
+ const outbox = await t.db.prepare('SELECT envelope FROM event_outbox').all();
assert.ok(outbox.length >= 5, `events were queued (${outbox.length})`);
assert.ok(outbox.length >= 5, `events were queued (${outbox.length})`);
const where = [];
const where = [];
- for (const row of outbox) { const f = leaksIn(row.envelope); if (f.length) where.push(`outbox: ${f.join(', ')}`); }
+ for (const row of outbox) { const f = leaksIn(typeof row.envelope === 'string' ? row.envelope : JSON.stringify(row.envelope)); if (f.length) where.push(`outbox: ${f.join(', ')}`); }
for (const table of ['relay_deliveries', 'relay_mappings', 'relay_inbound_failures', 'pastes', 'paste_comments', 'posts', 'threads', 'comments', 'comment_threads', 'pulse_items', 'search_doc_pushes']) {
for (const table of ['relay_deliveries', 'relay_mappings', 'relay_inbound_failures', 'pastes', 'paste_comments', 'posts', 'threads', 'comments', 'comment_threads', 'pulse_items', 'search_doc_pushes']) {
- for (const row of t.db.prepare(`SELECT * FROM ${table}`).all()) { const f = leaksIn(JSON.stringify(row)); if (f.length) where.push(`${table}: ${f.join(', ')}`); }
+ for (const row of await t.db.prepare(`SELECT * FROM ${table}`).all()) { const f = leaksIn(JSON.stringify(row)); if (f.length) where.push(`${table}: ${f.join(', ')}`); }
}
}
assert.deepStrictEqual(where, []);
assert.deepStrictEqual(where, []);
});
});
@@ -168,7 +168,7 @@ function rawGet(base, rawPath) {
@@ -168,7 +168,7 @@ function rawGet(base, rawPath) {
assert.strictEqual((await send('POST', '/api/pastes/screenshot', { body: f })).status, 201);
assert.strictEqual((await send('POST', '/api/pastes/screenshot', { body: f })).status, 201);
const cth = JSON.parse((await send('POST', '/api/v1/comments/threads/resolve', { json: { ref: { service: 'community', type: 'paste', id: JSON.parse(p.text).slug } } })).text).thread;
const cth = JSON.parse((await send('POST', '/api/v1/comments/threads/resolve', { json: { ref: { service: 'community', type: 'paste', id: JSON.parse(p.text).slug } } })).text).thread;
await send('POST', `/api/v1/comments/threads/${cth.id}/comments`, { json: { message: ` ${urls[4]}` } });
await send('POST', `/api/v1/comments/threads/${cth.id}/comments`, { json: { message: ` ${urls[4]}` } });
- t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(u.subject_id);
+ await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(u.subject_id);
await send('PUT', '/api/v1/spaces/general/chat-room', { json: { room: urls[7] } });
await send('PUT', '/api/v1/spaces/general/chat-room', { json: { room: urls[7] } });
const map = await send('POST', '/api/v1/relay/mappings', { cookie: bossJwt, json: { space: 'general', webhook_url_ref: `${C}/hook` } });
const map = await send('POST', '/api/v1/relay/mappings', { cookie: bossJwt, json: { space: 'general', webhook_url_ref: `${C}/hook` } });
assert.strictEqual(map.status, 400, 'a mapping names a variable, never a URL');
assert.strictEqual(map.status, 400, 'a mapping names a variable, never a URL');
@@ -58,7 +58,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -58,7 +58,7 @@ const { boot, check, done } = require('./helpers/app');
await check('an app token naming a victim in X-OV-Subject cannot edit their forum post or post as them', async () => {
await check('an app token naming a victim in X-OV-Subject cannot edit their forum post or post as them', async () => {
const r = await call(`/api/v1/posts/${alexPostId}`, { method: 'PUT', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { body: 'defaced' } });
const r = await call(`/api/v1/posts/${alexPostId}`, { method: 'PUT', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { body: 'defaced' } });
assert.notStrictEqual(r.status, 200, `post edited by a third-party app: ${r.text}`);
assert.notStrictEqual(r.status, 200, `post edited by a third-party app: ${r.text}`);
- const post = t.db.prepare('SELECT body_markdown FROM posts WHERE id = ?').get(alexPostId);
+ const post = await t.db.prepare('SELECT body_markdown FROM posts WHERE id = ?').get(alexPostId);
assert.strictEqual(post.body_markdown, 'original words');
assert.strictEqual(post.body_markdown, 'original words');
const p = await call('/api/pastes', { method: 'POST', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'written as alex' } });
const p = await call('/api/pastes', { method: 'POST', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'written as alex' } });
assert.strictEqual(p.status, 403, p.text);
assert.strictEqual(p.status, 403, p.text);
@@ -167,14 +167,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -167,14 +167,14 @@ const { boot, check, done } = require('./helpers/app');
const pub = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'public words' } });
const pub = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'public words' } });
assert.match(pub.json().slug, SHORT, 'public pastes are listed anyway: short slug');
assert.match(pub.json().slug, SHORT, 'public pastes are listed anyway: short slug');
// A paste made before this change (short slug, unlisted) still opens by its slug.
// A paste made before this change (short slug, unlisted) still opens by its slug.
- t.db.prepare("INSERT INTO pastes (slug, type, title, content, language, visibility) VALUES ('calm-otter-42', 'paste', 'old', 'old unlisted words', 'text', 'unlisted')").run();
+ await t.db.prepare("INSERT INTO pastes (slug, type, title, content, language, visibility) VALUES ('calm-otter-42', 'paste', 'old', 'old unlisted words', 'text', 'unlisted')").run();
const old = await call('/api/pastes/calm-otter-42');
const old = await call('/api/pastes/calm-otter-42');
assert.strictEqual(old.status, 200, old.text);
assert.strictEqual(old.status, 200, old.text);
assert.strictEqual(old.json().paste.content, 'old unlisted words');
assert.strictEqual(old.json().paste.content, 'old unlisted words');
assert.strictEqual((await call('/p/calm-otter-42/raw')).status, 200);
assert.strictEqual((await call('/p/calm-otter-42/raw')).status, 200);
// The secret part is really random.
// The secret part is really random.
const { generateSlug } = require('../server/pastes/store');
const { generateSlug } = require('../server/pastes/store');
- const many = new Set(Array.from({ length: 2000 }, () => generateSlug(t.db, { secret: true }).split('-')[2]));
+ const many = new Set(Array.from({ length: 2000 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2]));
assert.strictEqual(many.size, 2000);
assert.strictEqual(many.size, 2000);
});
});
@@ -24,7 +24,7 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();
@@ -24,7 +24,7 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
cwd: path.join(__dirname, '..'),
cwd: path.join(__dirname, '..'),
env: {
env: {
- ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test', COMMUNITY_DB_PATH: path.join(dir, 'community.db'),
+ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test', COMMUNITY_PGLITE_DIR: path.join(dir, 'pglite'), DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '',
EVENTS_URL: 'http://127.0.0.1:9', OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9',
EVENTS_URL: 'http://127.0.0.1:9', OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9',
OV_OAUTH_CLIENT_SECRET: 'test-secret-not-real', COMMUNITY_EVENTS_SECRET: 'e'.repeat(40), DISCORD_RELAY_ENABLED: '1',
OV_OAUTH_CLIENT_SECRET: 'test-secret-not-real', COMMUNITY_EVENTS_SECRET: 'e'.repeat(40), DISCORD_RELAY_ENABLED: '1',
DISCORD_RELAY_INBOUND: 'on', DISCORD_BOT_TOKEN: 'not-a-real-bot-token', DISCORD_GATEWAY_URL: 'ws://127.0.0.1:9/?v=10&encoding=json',
DISCORD_RELAY_INBOUND: 'on', DISCORD_BOT_TOKEN: 'not-a-real-bot-token', DISCORD_GATEWAY_URL: 'ws://127.0.0.1:9/?v=10&encoding=json',
@@ -21,8 +21,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -21,8 +21,8 @@ const { boot, check, done } = require('./helpers/app');
const unl = (await svc.createText(asAlex, { title: 'secret notes', content: 'unlisted <script>alert(1)</script>', visibility: 'unlisted' })).slug;
const unl = (await svc.createText(asAlex, { title: 'secret notes', content: 'unlisted <script>alert(1)</script>', visibility: 'unlisted' })).slug;
const priv = (await svc.createText(asAlex, { title: 'private thing', content: 'private', visibility: 'private' })).slug;
const priv = (await svc.createText(asAlex, { title: 'private thing', content: 'private', visibility: 'private' })).slug;
const burn = (await svc.createText(asAlex, { title: 'burn me', content: 'once only', burn_after_read: true })).slug;
const burn = (await svc.createText(asAlex, { title: 'burn me', content: 'once only', burn_after_read: true })).slug;
- t.db.prepare('UPDATE pastes SET views = 900 WHERE slug = ?').run(nginx);
- t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, screenshot_url) VALUES ('fair-moon-23', ?, 'screenshot', 'Desktop shot', 'my desktop', 'https://openvibe.media/f/abc-desk.png')").run(alex.subject_id);
+ await t.db.prepare('UPDATE pastes SET views = 900 WHERE slug = ?').run(nginx);
+ await t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, screenshot_url) VALUES ('fair-moon-23', ?, 'screenshot', 'Desktop shot', 'my desktop', 'https://openvibe.media/f/abc-desk.png')").run(alex.subject_id);
await check('home: latest + most viewed come from the store; nothing hidden; Live is never asked', async () => {
await check('home: latest + most viewed come from the store; nothing hidden; Live is never asked', async () => {
const r = await t.get('/');
const r = await t.get('/');
@@ -42,7 +42,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -42,7 +42,7 @@ const { boot, check, done } = require('./helpers/app');
const ld = r.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/g).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
const ld = r.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/g).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
assert.strictEqual(ld.find((o) => o['@type'] === 'Article').author.name, 'Alex');
assert.strictEqual(ld.find((o) => o['@type'] === 'Article').author.name, 'Alex');
has(r.text, 'https://openvibe.media/avatars/alex.png');
has(r.text, 'https://openvibe.media/avatars/alex.png');
- assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(js).views, 1);
+ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(js)).views, 1);
assert.ok(!r.text.includes(`data-delete="${js}"`), 'no delete button for visitors');
assert.ok(!r.text.includes(`data-delete="${js}"`), 'no delete button for visitors');
});
});
@@ -68,12 +68,12 @@ const { boot, check, done } = require('./helpers/app');
@@ -68,12 +68,12 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('download reads the store without a view; screenshots use the stored Media URL', async () => {
await check('download reads the store without a view; screenshots use the stored Media URL', async () => {
- const before = t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views;
+ const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py)).views;
const dl = await t.get(`/p/${py}/download`);
const dl = await t.get(`/p/${py}/download`);
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.headers.get('content-disposition'), `attachment; filename="${py}.py"`);
assert.strictEqual(dl.headers.get('content-disposition'), `attachment; filename="${py}.py"`);
assert.ok(dl.text.startsWith('def add'));
assert.ok(dl.text.startsWith('def add'));
- assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views, before);
+ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py)).views, before);
const shot = await t.get('/p/fair-moon-23');
const shot = await t.get('/p/fair-moon-23');
has(shot.text, '<meta property="og:image" content="https://openvibe.media/f/abc-desk.png">');
has(shot.text, '<meta property="og:image" content="https://openvibe.media/f/abc-desk.png">');
const dls = await t.get('/p/fair-moon-23/download');
const dls = await t.get('/p/fair-moon-23/download');
@@ -104,7 +104,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -104,7 +104,7 @@ const { boot, check, done } = require('./helpers/app');
const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
assert.strictEqual(post.status, 303);
assert.strictEqual(post.status, 303);
const slug = post.headers.get('location').replace('/p/', '');
const slug = post.headers.get('location').replace('/p/', '');
- const row = t.db.prepare('SELECT owner_subject, visibility FROM pastes WHERE slug = ?').get(slug);
+ const row = await t.db.prepare('SELECT owner_subject, visibility FROM pastes WHERE slug = ?').get(slug);
assert.deepStrictEqual(row, { owner_subject: alex.subject_id, visibility: 'private' });
assert.deepStrictEqual(row, { owner_subject: alex.subject_id, visibility: 'private' });
const anonBody = new URLSearchParams({ content: 'hi' }).toString();
const anonBody = new URLSearchParams({ content: 'hi' }).toString();
const ip = '203.0.113.200';
const ip = '203.0.113.200';
@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/app');
const net = t.network;
const net = t.network;
const { createViewerResolver } = require('../server/identity/viewer');
const { createViewerResolver } = require('../server/identity/viewer');
const viewers = createViewerResolver({ auth: t.app.locals.auth, config: require('../server/config'), network: null });
const viewers = createViewerResolver({ auth: t.app.locals.auth, config: require('../server/config'), network: null });
- const resolve = (token) => viewers.resolve({ headers: { authorization: `Bearer ${token}` }, get: () => undefined, cookies: {} });
+ const resolve = async (token) => await viewers.resolve({ headers: { authorization: `Bearer ${token}` }, get: () => undefined, cookies: {} });
const tok = (claims) => net.sign({ id: Math.floor(Math.random() * 1e6), subject_id: ids.newId('user'), username: 'x', display_name: 'X', ...claims });
const tok = (claims) => net.sign({ id: Math.floor(Math.random() * 1e6), subject_id: ids.newId('user'), username: 'x', display_name: 'X', ...claims });
await check('a global moderator is paste and discussion staff', async () => {
await check('a global moderator is paste and discussion staff', async () => {
@@ -6,19 +6,12 @@
@@ -6,19 +6,12 @@
*/
*/
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const store = require('../server/pastes/store');
const store = require('../server/pastes/store');
const { createPasteService, PasteError } = require('../server/pastes/service');
const { createPasteService, PasteError } = require('../server/pastes/service');
const { check, done } = require('./helpers/app');
const { check, done } = require('./helpers/app');
-const db = openDb(':memory:');
-const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
-
-const alice = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
-const bob = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
-const mod = { kind: 'user', subject: ids.newId('user'), staff: true, origin: 'user' };
-const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
-store.upsertProjection(db, { subject_id: alice.subject, username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.media/a.png', profile_color: '#f0a' });
+let db, svc, alice, bob, mod, anon;
async function rejects(p, status, message) {
async function rejects(p, status, message) {
try { await p; } catch (err) {
try { await p; } catch (err) {
@@ -31,6 +24,13 @@ async function rejects(p, status, message) {
@@ -31,6 +24,13 @@ async function rejects(p, status, message) {
}
}
(async () => {
(async () => {
+ db = await testDb();
+ svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
+ alice = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
+ bob = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
+ mod = { kind: 'user', subject: ids.newId('user'), staff: true, origin: 'user' };
+ anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
+ await store.upsertProjection(db, { subject_id: alice.subject, username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.media/a.png', profile_color: '#f0a' });
let pub, unl, priv;
let pub, unl, priv;
await check('create: slug style, trimmed content, language detection, owner, Media response shape', async () => {
await check('create: slug style, trimmed content, language detection, owner, Media response shape', async () => {
@@ -81,14 +81,14 @@ async function rejects(p, status, message) {
@@ -81,14 +81,14 @@ async function rejects(p, status, message) {
assert.strictEqual((await svc.get(mod, priv, { noView: true })).paste.visibility, 'private');
assert.strictEqual((await svc.get(mod, priv, { noView: true })).paste.visibility, 'private');
// Private pastes can't be forked, liked, copied or commented by others either.
// Private pastes can't be forked, liked, copied or commented by others either.
await rejects(svc.fork(bob, priv), 404);
await rejects(svc.fork(bob, priv), 404);
- await rejects(Promise.resolve().then(() => svc.like(bob, priv)), 404);
- await rejects(Promise.resolve().then(() => svc.copy(anon, priv)), 404);
+ await rejects(Promise.resolve().then(async () => await svc.like(bob, priv)), 404);
+ await rejects(Promise.resolve().then(async () => await svc.copy(anon, priv)), 404);
await rejects(svc.comments(anon, priv), 404);
await rejects(svc.comments(anon, priv), 404);
});
});
await check('list: preview content (300 chars, null for screenshots), filters, owner view via username', async () => {
await check('list: preview content (300 chars, null for screenshots), filters, owner view via username', async () => {
const long = await svc.createText(bob, { title: 'long one', content: 'L'.repeat(1000) });
const long = await svc.createText(bob, { title: 'long one', content: 'L'.repeat(1000) });
- store.upsertProjection(db, { subject_id: bob.subject, username: 'bob', display_name: 'Bob' });
+ await store.upsertProjection(db, { subject_id: bob.subject, username: 'bob', display_name: 'Bob' });
const list = await svc.list(anon, { search: 'long one' });
const list = await svc.list(anon, { search: 'long one' });
assert.strictEqual(list.total, 1);
assert.strictEqual(list.total, 1);
assert.strictEqual(list.pastes[0].content.length, 300);
assert.strictEqual(list.pastes[0].content.length, 300);
@@ -99,7 +99,7 @@ async function rejects(p, status, message) {
@@ -99,7 +99,7 @@ async function rejects(p, status, message) {
assert.ok(!theirs.pastes.some((p) => p.slug === priv || p.slug === unl), 'others never do');
assert.ok(!theirs.pastes.some((p) => p.slug === priv || p.slug === unl), 'others never do');
assert.deepStrictEqual(await svc.list(anon, { username: 'nobody' }), { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });
assert.deepStrictEqual(await svc.list(anon, { username: 'nobody' }), { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });
const wildcard = await svc.list(anon, { search: '%' });
const wildcard = await svc.list(anon, { search: '%' });
- assert.strictEqual(wildcard.total, 0, 'LIKE wildcards are literal');
+ assert.strictEqual(wildcard.total, 0, 'ILIKE wildcards are literal');
assert.strictEqual((await svc.list(anon, { limit: 1 })).pastes.length, 1);
assert.strictEqual((await svc.list(anon, { limit: 1 })).pastes.length, 1);
assert.strictEqual(long.paste.username, null, 'no projection yet at creation → no name (not a crash)');
assert.strictEqual(long.paste.username, null, 'no projection yet at creation → no name (not a crash)');
});
});
@@ -125,7 +125,7 @@ async function rejects(p, status, message) {
@@ -125,7 +125,7 @@ async function rejects(p, status, message) {
const p = (await svc.get(anon, slug, { ip: '198.51.100.3' })).paste;
const p = (await svc.get(anon, slug, { ip: '198.51.100.3' })).paste;
assert.strictEqual(p.views, 3);
assert.strictEqual(p.views, 3);
assert.strictEqual(p.unique_views, 3);
assert.strictEqual(p.unique_views, 3);
- const visits = db.prepare('SELECT visitor FROM paste_visits WHERE paste_id = ?').all(p.id).map((r) => r.visitor);
+ const visits = (await db.prepare('SELECT visitor FROM paste_visits WHERE paste_id = ?').all(p.id)).map((r) => r.visitor);
assert.ok(visits.every((v) => !v.includes('198.51.100')), 'addresses are hashed, never stored');
assert.ok(visits.every((v) => !v.includes('198.51.100')), 'addresses are hashed, never stored');
});
});
@@ -137,13 +137,13 @@ async function rejects(p, status, message) {
@@ -137,13 +137,13 @@ async function rejects(p, status, message) {
assert.strictEqual(first.paste.burn_after_read, true);
assert.strictEqual(first.paste.burn_after_read, true);
await rejects(svc.get(anon, slug, { ip: '203.0.113.5' }), 410, 'This paste has been burned after reading.');
await rejects(svc.get(anon, slug, { ip: '203.0.113.5' }), 410, 'This paste has been burned after reading.');
await rejects(svc.get(alice, slug), 404);
await rejects(svc.get(alice, slug), 404);
- const row = db.prepare('SELECT content, deleted_at FROM pastes WHERE slug = ?').get(slug);
+ const row = await db.prepare('SELECT content, deleted_at FROM pastes WHERE slug = ?').get(slug);
assert.strictEqual(row.content, null, 'content scrubbed');
assert.strictEqual(row.content, null, 'content scrubbed');
assert.ok(row.deleted_at);
assert.ok(row.deleted_at);
// Raw text: Media's rule — the first read serves it, the next one burns it.
// Raw text: Media's rule — the first read serves it, the next one burns it.
const raw = (await svc.createText(alice, { content: 'raw secret', burn_after_read: true })).slug;
const raw = (await svc.createText(alice, { content: 'raw secret', burn_after_read: true })).slug;
- assert.strictEqual(svc.raw(bob, raw).content, 'raw secret');
- await rejects(Promise.resolve().then(() => svc.raw(bob, raw)), 410);
+ assert.strictEqual((await svc.raw(bob, raw)).content, 'raw secret');
+ await rejects(Promise.resolve().then(async () => await svc.raw(bob, raw)), 410);
});
});
await check('fork: text only, "Fork of …", public, forked_from set; like toggles; copy counts', async () => {
await check('fork: text only, "Fork of …", public, forked_from set; like toggles; copy counts', async () => {
@@ -152,16 +152,16 @@ async function rejects(p, status, message) {
@@ -152,16 +152,16 @@ async function rejects(p, status, message) {
assert.strictEqual(f.paste.visibility, 'public');
assert.strictEqual(f.paste.visibility, 'public');
assert.strictEqual(f.paste.owner_subject, bob.subject);
assert.strictEqual(f.paste.owner_subject, bob.subject);
assert.strictEqual(f.paste.forked_from, (await svc.get(anon, pub, { noView: true })).paste.id);
assert.strictEqual(f.paste.forked_from, (await svc.get(anon, pub, { noView: true })).paste.id);
- db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url) VALUES ('shot-only-1', 'screenshot', 'S', 'https://openvibe.media/f/x.png')").run();
+ await db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url) VALUES ('shot-only-1', 'screenshot', 'S', 'https://openvibe.media/f/x.png')").run();
await rejects(svc.fork(bob, 'shot-only-1'), 400, 'Only text pastes can be forked');
await rejects(svc.fork(bob, 'shot-only-1'), 400, 'Only text pastes can be forked');
- assert.deepStrictEqual(svc.like(bob, pub), { liked: true, likes: 1 });
- assert.deepStrictEqual(svc.like(alice, pub), { liked: true, likes: 2 });
- assert.deepStrictEqual(svc.like(bob, pub), { liked: false, likes: 1 });
- await rejects(Promise.resolve().then(() => svc.like(anon, pub)), 401, 'Authentication required');
+ assert.deepStrictEqual(await svc.like(bob, pub), { liked: true, likes: 1 });
+ assert.deepStrictEqual(await svc.like(alice, pub), { liked: true, likes: 2 });
+ assert.deepStrictEqual(await svc.like(bob, pub), { liked: false, likes: 1 });
+ await rejects(Promise.resolve().then(async () => await svc.like(anon, pub)), 401, 'Authentication required');
assert.strictEqual((await svc.get(alice, pub, { noView: true })).paste.liked, true);
assert.strictEqual((await svc.get(alice, pub, { noView: true })).paste.liked, true);
assert.strictEqual((await svc.get(bob, pub, { noView: true })).paste.liked, false);
assert.strictEqual((await svc.get(bob, pub, { noView: true })).paste.liked, false);
- assert.deepStrictEqual(svc.copy(anon, pub), { copies: 1 });
- assert.deepStrictEqual(svc.copy(anon, pub), { copies: 2 });
+ assert.deepStrictEqual(await svc.copy(anon, pub), { copies: 1 });
+ assert.deepStrictEqual(await svc.copy(anon, pub), { copies: 2 });
});
});
await check('comments: anonymous with a cleaned name, replies one level deep, newest first, delete rules', async () => {
await check('comments: anonymous with a cleaned name, replies one level deep, newest first, delete rules', async () => {
@@ -185,11 +185,11 @@ async function rejects(p, status, message) {
@@ -185,11 +185,11 @@ async function rejects(p, status, message) {
assert.strictEqual(list.comments[0].replies[0].username, 'alice');
assert.strictEqual(list.comments[0].replies[0].username, 'alice');
assert.strictEqual(list.comments[0].replies[0].display_name, 'Alice');
assert.strictEqual(list.comments[0].replies[0].display_name, 'Alice');
// Delete: author, paste owner or staff; everyone else 403; anonymous 401.
// Delete: author, paste owner or staff; everyone else 403; anonymous 401.
- await rejects(Promise.resolve().then(() => svc.deleteComment(anon, pub, b.id)), 401);
- await rejects(Promise.resolve().then(() => svc.deleteComment({ kind: 'user', subject: ids.newId('user'), staff: false }, pub, b.id)), 403, 'Not authorized to delete this comment');
- assert.deepStrictEqual(svc.deleteComment(bob, pub, b.id), { message: 'Comment deleted' });
- assert.deepStrictEqual(svc.deleteComment(alice, pub, a.id), { message: 'Comment deleted' }, 'paste owner');
- await rejects(Promise.resolve().then(() => svc.deleteComment(alice, pub, 424242)), 404, 'Comment not found');
+ await rejects(Promise.resolve().then(async () => await svc.deleteComment(anon, pub, b.id)), 401);
+ await rejects(Promise.resolve().then(async () => await svc.deleteComment({ kind: 'user', subject: ids.newId('user'), staff: false }, pub, b.id)), 403, 'Not authorized to delete this comment');
+ assert.deepStrictEqual(await svc.deleteComment(bob, pub, b.id), { message: 'Comment deleted' });
+ assert.deepStrictEqual(await svc.deleteComment(alice, pub, a.id), { message: 'Comment deleted' }, 'paste owner');
+ await rejects(Promise.resolve().then(async () => await svc.deleteComment(alice, pub, 424242)), 404, 'Comment not found');
assert.strictEqual((await svc.comments(anon, pub)).total, 1, 'deleted comments are hidden (the reply remains)');
assert.strictEqual((await svc.comments(anon, pub)).total, 1, 'deleted comments are hidden (the reply remains)');
});
});
@@ -215,15 +215,15 @@ async function rejects(p, status, message) {
@@ -215,15 +215,15 @@ async function rejects(p, status, message) {
// Anonymous callers aren't limited here (the per-address limiter in api.js is).
// Anonymous callers aren't limited here (the per-address limiter in api.js is).
await strict.createText(anon, { content: 'x' });
await strict.createText(anon, { content: 'x' });
await strict.createText(anon, { content: 'y' });
await strict.createText(anon, { content: 'y' });
- assert.deepStrictEqual(Object.keys(capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);
- assert.strictEqual(capped.config(dave).todayCount, 1);
+ assert.deepStrictEqual(Object.keys(await capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);
+ assert.strictEqual((await capped.config(dave)).todayCount, 1);
});
});
await check('update: owner or staff; versions appended; pinned staff-only; errors', async () => {
await check('update: owner or staff; versions appended; pinned staff-only; errors', async () => {
await rejects(svc.update(anon, pub, { title: 'x' }), 401, 'Authentication required');
await rejects(svc.update(anon, pub, { title: 'x' }), 401, 'Authentication required');
await rejects(svc.update(bob, pub, { title: 'x' }), 403, 'Not authorized for this paste');
await rejects(svc.update(bob, pub, { title: 'x' }), 403, 'Not authorized for this paste');
await rejects(svc.update(bob, priv, { title: 'x' }), 404, 'Paste not found');
await rejects(svc.update(bob, priv, { title: 'x' }), 404, 'Paste not found');
- await rejects(Promise.resolve().then(() => svc.remove(bob, priv)), 404, 'Paste not found');
+ await rejects(Promise.resolve().then(async () => await svc.remove(bob, priv)), 404, 'Paste not found');
await rejects(svc.update(alice, pub, {}), 400, 'Nothing to update');
await rejects(svc.update(alice, pub, {}), 400, 'Nothing to update');
await rejects(svc.update(alice, pub, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Too large');
await rejects(svc.update(alice, pub, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Too large');
const u = await svc.update(alice, pub, { content: 'def f():\n pass', pinned: true });
const u = await svc.update(alice, pub, { content: 'def f():\n pass', pinned: true });
@@ -233,46 +233,46 @@ async function rejects(p, status, message) {
@@ -233,46 +233,46 @@ async function rejects(p, status, message) {
const m = await svc.update(mod, pub, { title: 'Renamed', pinned: true });
const m = await svc.update(mod, pub, { title: 'Renamed', pinned: true });
assert.strictEqual(m.paste.pinned, true);
assert.strictEqual(m.paste.pinned, true);
assert.strictEqual(m.paste.revision, 3);
assert.strictEqual(m.paste.revision, 3);
- const v = svc.versions(alice, pub);
+ const v = await svc.versions(alice, pub);
assert.deepStrictEqual(v.versions.map((x) => x.revision), [1, 2, 3]);
assert.deepStrictEqual(v.versions.map((x) => x.revision), [1, 2, 3]);
assert.strictEqual(v.versions[0].content, 'const x = 1;', 'original snapshotted');
assert.strictEqual(v.versions[0].content, 'const x = 1;', 'original snapshotted');
assert.strictEqual(v.versions[1].edited_by, alice.subject);
assert.strictEqual(v.versions[1].edited_by, alice.subject);
assert.strictEqual(v.versions[2].edited_by, mod.subject);
assert.strictEqual(v.versions[2].edited_by, mod.subject);
await svc.update(alice, pub, { visibility: 'unlisted' });
await svc.update(alice, pub, { visibility: 'unlisted' });
- assert.strictEqual(svc.versions(alice, pub).revision, 3, 'visibility changes are not text revisions');
- await rejects(Promise.resolve().then(() => svc.versions(bob, unl)), 403);
+ assert.strictEqual((await svc.versions(alice, pub)).revision, 3, 'visibility changes are not text revisions');
+ await rejects(Promise.resolve().then(async () => await svc.versions(bob, unl)), 403);
});
});
await check('delete: owner or staff; soft delete keeps the slug reserved; gone from reads and lists', async () => {
await check('delete: owner or staff; soft delete keeps the slug reserved; gone from reads and lists', async () => {
- await rejects(Promise.resolve().then(() => svc.remove(bob, unl)), 403);
- assert.deepStrictEqual(svc.remove(alice, unl), { success: true });
+ await rejects(Promise.resolve().then(async () => await svc.remove(bob, unl)), 403);
+ assert.deepStrictEqual(await svc.remove(alice, unl), { success: true });
await rejects(svc.get(alice, unl), 404);
await rejects(svc.get(alice, unl), 404);
- assert.deepStrictEqual(svc.remove(mod, priv), { success: true }, 'staff');
- const row = db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);
+ assert.deepStrictEqual(await svc.remove(mod, priv), { success: true }, 'staff');
+ const row = await db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);
assert.ok(row && row.deleted_at && row.content === null);
assert.ok(row && row.deleted_at && row.content === null);
assert.ok(!(await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 200 })).pastes.some((p) => p.slug === unl));
assert.ok(!(await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 200 })).pastes.some((p) => p.slug === unl));
});
});
await check('staff tools: stats, forks, bulk, AI write-back', async () => {
await check('staff tools: stats, forks, bulk, AI write-back', async () => {
- const stats = svc.stats().stats;
+ const stats = (await svc.stats()).stats;
assert.ok(stats.total > 0 && stats.forks >= 1 && stats.textPastes > 0);
assert.ok(stats.total > 0 && stats.forks >= 1 && stats.textPastes > 0);
- assert.ok(svc.forks({}).forks.length >= 1);
- const b = svc.bulk({ slugs: [pub, 'no-such-slug'], action: 'unlisted' });
+ assert.ok((await svc.forks({})).forks.length >= 1);
+ const b = await svc.bulk({ slugs: [pub, 'no-such-slug'], action: 'unlisted' });
assert.deepStrictEqual(b, { done: 1, skipped: 1 });
assert.deepStrictEqual(b, { done: 1, skipped: 1 });
- assert.throws(() => svc.bulk({ slugs: [], action: 'delete' }), /No slugs provided/);
- assert.throws(() => svc.bulk({ slugs: [pub], action: 'nuke' }), /Invalid action/);
+ await assert.rejects(async () => await svc.bulk({ slugs: [], action: 'delete' }), /No slugs provided/);
+ await assert.rejects(async () => await svc.bulk({ slugs: [pub], action: 'nuke' }), /Invalid action/);
const ai = await svc.setAi(mod, pub, { ai_summary: 's'.repeat(3000), ai_tags: ['a', 'b'] });
const ai = await svc.setAi(mod, pub, { ai_summary: 's'.repeat(3000), ai_tags: ['a', 'b'] });
assert.strictEqual(ai.ok, true);
assert.strictEqual(ai.ok, true);
assert.strictEqual(ai.paste.ai_summary.length, 2000);
assert.strictEqual(ai.paste.ai_summary.length, 2000);
assert.strictEqual(ai.paste.ai_tags, '["a","b"]');
assert.strictEqual(ai.paste.ai_tags, '["a","b"]');
assert.ok(ai.paste.ai_analyzed_at);
assert.ok(ai.paste.ai_analyzed_at);
- const del = svc.deleteForks();
+ const del = await svc.deleteForks();
assert.ok(del.deleted >= 1 && del.success === true);
assert.ok(del.deleted >= 1 && del.success === true);
});
});
await check('generateSlug never reuses a slug, even a deleted one', async () => {
await check('generateSlug never reuses a slug, even a deleted one', async () => {
- const seen = new Set(db.prepare('SELECT slug FROM pastes').all().map((r) => r.slug));
- for (let i = 0; i < 200; i++) { const s = store.generateSlug(db); assert.ok(!seen.has(s)); }
+ const seen = new Set((await db.prepare('SELECT slug FROM pastes').all()).map((r) => r.slug));
+ for (let i = 0; i < 200; i++) { const s = await store.generateSlug(db); assert.ok(!seen.has(s)); }
});
});
db.close();
db.close();
@@ -10,34 +10,38 @@ const assert = require('assert');
@@ -10,34 +10,38 @@ const assert = require('assert');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
const { createPulseConsumer, TOPICS } = require('../server/pulse/consumer');
-const SECRET = `whsec_${'ef'.repeat(32)}`;
-const db = openDb(':memory:');
-const app = express();
-app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], log: { error() {}, log() {}, warn() {} } }).router);
-const KEEP = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1', FOLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2', OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
-const MERGE = 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4';
-const evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN09', event_type: 'network.subject.merged', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
- subject: { type: 'user', id: KEEP }, actor: { type: 'user', id: KEEP },
- payload: { merge_id: MERGE, from: FOLD, into: KEEP, merged_at: new Date().toISOString(), initiated_by: 'person' }, ...over });
+let SECRET, db, app, KEEP, FOLD, OTHER, MERGE, evt;
// What the folded-in account left in Community.
// What the folded-in account left in Community.
-db.prepare("INSERT INTO pastes (id, slug, owner_subject, likes) VALUES (1, 'p1', ?, 2), (2, 'p2', ?, 1)").run(FOLD, OTHER);
-db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (1, ?), (1, ?), (2, ?)').run(FOLD, KEEP, FOLD); // paste 1: both liked
-db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) VALUES (1, 'live', 'vod', '9')").run();
-db.prepare("INSERT INTO comments (id, thread_id, message, author_subject, score, upvotes, downvotes) VALUES (1, 1, 'hi', ?, 2, 2, 0)").run(OTHER);
-db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (1, ?, 1), (1, ?, 1)').run(FOLD, KEEP);
-db.prepare("INSERT INTO spaces (id, slug, name) VALUES (901, 'merge-test', 'Merge test')").run();
-db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) VALUES (1, 901, 't1', 'T1', ?, 1)").run(FOLD);
-db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (1, ?, 1)').run(FOLD);
-db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject) VALUES (1, 1, 'hello', ?)").run(FOLD);
-db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (1, ?, 'fire'), (1, ?, 'heart')").run(FOLD, KEEP);
-db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 5, '2026-09-27')").run(FOLD, KEEP);
-require('../server/identity/blocks').ensureSchema(db);
-db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(FOLD, OTHER, FOLD, KEEP);
+
+ // paste 1: both liked
(async () => {
(async () => {
+ SECRET = `whsec_${'ef'.repeat(32)}`;
+ db = await testDb();
+ app = express();
+ app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], log: { error() {}, log() {}, warn() {} } }).router);
+ KEEP = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
+ FOLD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP2';
+ OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP3';
+ MERGE = 'mrg_01JAB2C3D4E5F6G7H8J9K0MNP4';
+ evt = (over = {}) => ({ event_id: 'evt_01JAB2C3D4E5F6G7H8J9K0MN09', event_type: 'network.subject.merged', version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
+ subject: { type: 'user', id: KEEP }, actor: { type: 'user', id: KEEP },
+ payload: { merge_id: MERGE, from: FOLD, into: KEEP, merged_at: new Date().toISOString(), initiated_by: 'person' }, ...over });
+ await db.prepare("INSERT INTO pastes (id, slug, owner_subject, likes) OVERRIDING SYSTEM VALUE VALUES (1, 'p1', ?, 2), (2, 'p2', ?, 1)").run(FOLD, OTHER);
+ await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (1, ?), (1, ?), (2, ?)').run(FOLD, KEEP, FOLD);
+ await db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'vod', '9')").run();
+ await db.prepare("INSERT INTO comments (id, thread_id, message, author_subject, score, upvotes, downvotes) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'hi', ?, 2, 2, 0)").run(OTHER);
+ await db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (1, ?, 1), (1, ?, 1)').run(FOLD, KEEP);
+ await db.prepare("INSERT INTO spaces (id, slug, name) OVERRIDING SYSTEM VALUE VALUES (901, 'merge-test', 'Merge test')").run();
+ await db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) OVERRIDING SYSTEM VALUE VALUES (1, 901, 't1', 'T1', ?, 1)").run(FOLD);
+ await db.prepare('INSERT INTO thread_votes (thread_id, subject_id, value) VALUES (1, ?, 1)').run(FOLD);
+ await db.prepare("INSERT INTO posts (id, thread_id, body_markdown, author_subject) OVERRIDING SYSTEM VALUE VALUES (1, 1, 'hello', ?)").run(FOLD);
+ await db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (1, ?, 'fire'), (1, ?, 'heart')").run(FOLD, KEEP);
+ await db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 5, '2026-09-27')").run(FOLD, KEEP);
+ await db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(FOLD, OTHER, FOLD, KEEP);
assert.ok(TOPICS.includes('network.subject.merged'), 'Community subscribes to merges');
assert.ok(TOPICS.includes('network.subject.merged'), 'Community subscribes to merges');
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const post = (event) => {
const post = (event) => {
@@ -52,20 +56,20 @@ db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active
@@ -52,20 +56,20 @@ db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active
r = await post(evt());
r = await post(evt());
assert.deepStrictEqual([r.status, r.json.outcome], [200, 'merge:applied']);
assert.deepStrictEqual([r.status, r.json.outcome], [200, 'merge:applied']);
- const owner = (sql, ...a) => db.prepare(sql).get(...a);
- assert.strictEqual(owner('SELECT owner_subject AS s FROM pastes WHERE id = 1').s, KEEP);
- assert.strictEqual(owner('SELECT author_subject AS a FROM threads WHERE id = 1').a, KEEP);
- assert.strictEqual(owner('SELECT author_subject AS a FROM posts WHERE id = 1').a, KEEP);
+ const owner = async (sql, ...a) => await db.prepare(sql).get(...a);
+ assert.strictEqual((await owner('SELECT owner_subject AS s FROM pastes WHERE id = 1')).s, KEEP);
+ assert.strictEqual((await owner('SELECT author_subject AS a FROM threads WHERE id = 1')).a, KEEP);
+ assert.strictEqual((await owner('SELECT author_subject AS a FROM posts WHERE id = 1')).a, KEEP);
// Likes: paste 1 had both (one dropped, count 1); paste 2's like moved.
// Likes: paste 1 had both (one dropped, count 1); paste 2's like moved.
- assert.deepStrictEqual(db.prepare('SELECT paste_id AS p, subject_id AS s FROM paste_likes ORDER BY p').all().map((x) => [x.p, x.s]), [[1, KEEP], [2, KEEP]]);
- assert.strictEqual(owner('SELECT likes FROM pastes WHERE id = 1').likes, 1, 'the cached count follows the rows');
+ assert.deepStrictEqual((await db.prepare('SELECT paste_id AS p, subject_id AS s FROM paste_likes ORDER BY p').all()).map((x) => [x.p, x.s]), [[1, KEEP], [2, KEEP]]);
+ assert.strictEqual((await owner('SELECT likes FROM pastes WHERE id = 1')).likes, 1, 'the cached count follows the rows');
// Votes: comment 1 had both (score recomputed to 1); thread 1's vote moved.
// Votes: comment 1 had both (score recomputed to 1); thread 1's vote moved.
- assert.deepStrictEqual(owner('SELECT score, upvotes, downvotes FROM comments WHERE id = 1'), { score: 1, upvotes: 1, downvotes: 0 });
- assert.deepStrictEqual(db.prepare('SELECT subject_id AS s FROM thread_votes').all().map((x) => x.s), [KEEP]);
- assert.deepStrictEqual(db.prepare('SELECT subject_id AS s, reaction FROM post_reactions').all(), [{ s: KEEP, reaction: 'heart' }], 'the survivor\'s reaction stays');
- assert.deepStrictEqual(db.prepare('SELECT subject_id AS s, level FROM game_progress').all(), [{ s: KEEP, level: 5 }]);
- assert.deepStrictEqual(db.prepare('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').all(), [{ a: KEEP, b: OTHER }], 'moved; blocking oneself dropped');
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM pastes WHERE owner_subject = ?').get(FOLD).n + db.prepare('SELECT COUNT(*) AS n FROM paste_likes WHERE subject_id = ?').get(FOLD).n, 0);
+ assert.deepStrictEqual(await owner('SELECT score, upvotes, downvotes FROM comments WHERE id = 1'), { score: 1, upvotes: 1, downvotes: 0 });
+ assert.deepStrictEqual((await db.prepare('SELECT subject_id AS s FROM thread_votes').all()).map((x) => x.s), [KEEP]);
+ assert.deepStrictEqual(await db.prepare('SELECT subject_id AS s, reaction FROM post_reactions').all(), [{ s: KEEP, reaction: 'heart' }], 'the survivor\'s reaction stays');
+ assert.deepStrictEqual(await db.prepare('SELECT subject_id AS s, level FROM game_progress').all(), [{ s: KEEP, level: 5 }]);
+ assert.deepStrictEqual(await db.prepare('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').all(), [{ a: KEEP, b: OTHER }], 'moved; blocking oneself dropped');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM pastes WHERE owner_subject = ?').get(FOLD)).n + (await db.prepare('SELECT COUNT(*) AS n FROM paste_likes WHERE subject_id = ?').get(FOLD)).n, 0);
r = await post(evt());
r = await post(evt());
assert.deepStrictEqual([r.status, r.json.duplicate], [200, true], 'a redelivery changes nothing');
assert.deepStrictEqual([r.status, r.json.duplicate], [200, true], 'a redelivery changes nothing');
@@ -1,110 +1,81 @@
@@ -1,110 +1,81 @@
'use strict';
'use strict';
/**
/**
- * Vote races. A vote is one UPSERT plus a score recomputed from the rows in the same IMMEDIATE
- * transaction (server/votes.js), so however writes interleave the stored score is the sum of the
- * vote rows. Checked two ways against a real database file:
- * 1. two connections in one process, their writes interleaved step by step;
- * 2. four worker threads hammering the same comment and thread at once.
+ * Vote races. A vote is one UPSERT plus a score recomputed from the rows in the same transaction, after locking
+ * the target row (server/votes.js), so however writes interleave the stored score is the sum of the vote rows.
+ * Checked on a real database: interleaved writes step by step, a stale reader, then 1000 votes with 32 in flight at a
+ * time (under npm run test:pg they run on separate PostgreSQL connections through PgBouncer).
*/
*/
-const { Worker, isMainThread, workerData, parentPort } = require('worker_threads');
-const path = require('path');
-const fs = require('fs');
-const os = require('os');
-
-if (!isMainThread) {
- // ── worker: random votes by a small crowd, as fast as it can ──
- const { openDb } = require('../server/db');
- const { applyVote } = require('../server/votes');
- const db = openDb(workerData.file);
- let seed = workerData.seed;
- const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
- let done = 0;
- try {
- for (let i = 0; i < workerData.ops; i++) {
- const subject = workerData.subjects[rand(workerData.subjects.length)];
- const value = [1, -1, 0][rand(3)];
- if (rand(2)) applyVote(db, 'comment', workerData.commentId, subject, value);
- else applyVote(db, 'thread', workerData.threadId, subject, value);
- done++;
- }
- parentPort.postMessage({ ok: true, done });
- } catch (err) {
- parentPort.postMessage({ ok: false, done, error: err.message });
- } finally { db.close(); }
- return;
-}
-
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const { applyVote } = require('../server/votes');
const { applyVote } = require('../server/votes');
const commentStore = require('../server/comments/store');
const commentStore = require('../server/comments/store');
const forumStore = require('../server/forum/store');
const forumStore = require('../server/forum/store');
const { check, done } = require('./helpers/app');
const { check, done } = require('./helpers/app');
-const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-votes-'));
-const file = path.join(dir, 'votes.db');
-const a = openDb(file);
-const b = openDb(file);
-
-const { thread: cthread } = commentStore.resolveThread(a, { service: 'live', type: 'vod', id: 'race' });
-const comment = commentStore.insertComment(a, { thread_id: cthread.id, message: 'vote on me' });
-const space = forumStore.getSpace(a, 'general');
-const { thread } = forumStore.createThread(a, { space_id: space.id, title: 'Race thread', body_markdown: 'go' });
-const subjects = Array.from({ length: 10 }, () => ids.newId('user'));
+let db, comment, thread, subjects;
-function invariant(db, label) {
- const c = db.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);
- const cv = db.prepare(`SELECT COALESCE(SUM(value), 0) AS s, COALESCE(SUM(value = 1), 0) AS up, COALESCE(SUM(value = -1), 0) AS down FROM comment_votes WHERE comment_id = ?`).get(comment.id);
+async function invariant(label) {
+ const c = await db.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);
+ const cv = await db.prepare(`SELECT COALESCE(SUM(value), 0)::bigint AS s, COUNT(*) FILTER (WHERE value = 1) AS up, COUNT(*) FILTER (WHERE value = -1) AS down
+ FROM comment_votes WHERE comment_id = ?`).get(comment.id);
assert.deepStrictEqual([c.score, c.upvotes, c.downvotes], [cv.s, cv.up, cv.down], `${label}: comment score is the sum of its votes`);
assert.deepStrictEqual([c.score, c.upvotes, c.downvotes], [cv.s, cv.up, cv.down], `${label}: comment score is the sum of its votes`);
- const t = db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);
- const tv = db.prepare('SELECT COALESCE(SUM(value), 0) AS s FROM thread_votes WHERE thread_id = ?').get(thread.id);
+ const t = await db.prepare('SELECT score FROM threads WHERE id = ?').get(thread.id);
+ const tv = await db.prepare('SELECT COALESCE(SUM(value), 0)::bigint AS s FROM thread_votes WHERE thread_id = ?').get(thread.id);
assert.strictEqual(t.score, tv.s, `${label}: thread score is the sum of its votes`);
assert.strictEqual(t.score, tv.s, `${label}: thread score is the sum of its votes`);
- const dup = db.prepare('SELECT COUNT(*) AS n FROM (SELECT subject_id FROM comment_votes WHERE comment_id = ? GROUP BY subject_id HAVING COUNT(*) > 1)').get(comment.id).n;
+ const dup = (await db.prepare('SELECT COUNT(*) AS n FROM (SELECT subject_id FROM comment_votes WHERE comment_id = ? GROUP BY subject_id HAVING COUNT(*) > 1) d').get(comment.id)).n;
assert.strictEqual(dup, 0, `${label}: one vote per person`);
assert.strictEqual(dup, 0, `${label}: one vote per person`);
}
}
(async () => {
(async () => {
- await check('interleaved UPSERTs on two connections: add, flip, remove — the score never drifts', () => {
+ db = await testDb({ max: 8 });
+ const { thread: cthread } = await commentStore.resolveThread(db, { service: 'live', type: 'vod', id: 'race' });
+ comment = await commentStore.insertComment(db, { thread_id: cthread.id, message: 'vote on me' });
+ const space = await forumStore.getSpace(db, 'general');
+ ({ thread } = await forumStore.createThread(db, { space_id: space.id, title: 'Race thread', body_markdown: 'go' }));
+ subjects = Array.from({ length: 10 }, () => ids.newId('user'));
+
+ await check('interleaved votes: add, flip, remove — the score never drifts', async () => {
const [s1, s2, s3] = subjects;
const [s1, s2, s3] = subjects;
- const steps = [
- [a, s1, 1], [b, s1, -1], [a, s2, 1], [b, s2, 1], [a, s1, -1], [b, s3, -1],
- [a, s3, 0], [b, s1, 1], [a, s2, 0], [b, s2, -1], [a, s3, 1], [b, s3, 1],
- ];
- for (const [db, s, v] of steps) {
- const out = applyVote(db, 'comment', comment.id, s, v);
- applyVote(db === a ? b : a, 'thread', thread.id, s, v);
- invariant(a, 'after step'); invariant(b, 'other connection');
+ const steps = [[s1, 1], [s1, -1], [s2, 1], [s2, 1], [s1, -1], [s3, -1], [s3, 0], [s1, 1], [s2, 0], [s2, -1], [s3, 1], [s3, 1]];
+ for (const [s, v] of steps) {
+ const out = await applyVote(db, 'comment', comment.id, s, v);
+ await applyVote(db, 'thread', thread.id, s, v);
+ await invariant('after step');
assert.strictEqual(out.my_vote, v);
assert.strictEqual(out.my_vote, v);
}
}
// Final state is the last write per person: s1 +1, s2 −1, s3 +1.
// Final state is the last write per person: s1 +1, s2 −1, s3 +1.
- const c = a.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);
- assert.deepStrictEqual(c, { score: 1, upvotes: 2, downvotes: 1 });
+ const c = await db.prepare('SELECT score, upvotes, downvotes FROM comments WHERE id = ?').get(comment.id);
+ assert.deepStrictEqual({ ...c }, { score: 1, upvotes: 2, downvotes: 1 });
});
});
- await check('a stale reader cannot overwrite a newer score: the recount reads the rows inside the write lock', () => {
- // Connection b reads, a writes, then b writes: b's score comes from the rows, not from what it read.
- const staleScore = b.prepare('SELECT score FROM comments WHERE id = ?').get(comment.id).score;
- applyVote(a, 'comment', comment.id, subjects[4], 1);
- const out = applyVote(b, 'comment', comment.id, subjects[5], 1);
+ await check('a stale reader cannot overwrite a newer score: the recount reads the rows under the row lock', async () => {
+ const staleScore = (await db.prepare('SELECT score FROM comments WHERE id = ?').get(comment.id)).score;
+ await applyVote(db, 'comment', comment.id, subjects[4], 1);
+ const out = await applyVote(db, 'comment', comment.id, subjects[5], 1);
assert.strictEqual(out.score, staleScore + 2);
assert.strictEqual(out.score, staleScore + 2);
- invariant(a, 'stale reader');
+ await invariant('stale reader');
});
});
- await check('four worker threads voting concurrently on one comment and one thread: invariants hold, no busy errors', async () => {
- const results = await Promise.all([1, 2, 3, 4].map((n) => new Promise((resolve, reject) => {
- const w = new Worker(__filename, { workerData: { file, seed: n * 7919, ops: 250, subjects, commentId: comment.id, threadId: thread.id } });
- w.once('message', resolve);
- w.once('error', reject);
- })));
- for (const r of results) assert.ok(r.ok, `worker failed: ${r.error}`);
- assert.strictEqual(results.reduce((s, r) => s + r.done, 0), 1000);
- invariant(a, 'after the storm');
- const rows = a.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(comment.id).n;
+ await check('1000 votes, 32 in flight at a time, on one comment and one thread: invariants hold, none fails', async () => {
+ let seed = 7919;
+ const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
+ const ops = Array.from({ length: 1000 }, () => [subjects[rand(subjects.length)], [1, -1, 0][rand(3)], rand(2)]);
+ // 32 in flight at a time: more than the pool's connections, so votes queue and interleave on the row locks.
+ const failed = [];
+ let next = 0;
+ await Promise.all(Array.from({ length: 32 }, async () => {
+ while (next < ops.length) {
+ const [s, v, onComment] = ops[next++];
+ try { await (onComment ? await applyVote(db, 'comment', comment.id, s, v) : await applyVote(db, 'thread', thread.id, s, v)); } catch (err) { failed.push(err); }
+ }
+ }));
+ assert.strictEqual(failed.length, 0, failed.length ? failed[0].message : '');
+ await invariant('after the storm');
+ const rows = (await db.prepare('SELECT COUNT(*) AS n FROM comment_votes WHERE comment_id = ?').get(comment.id)).n;
assert.ok(rows <= subjects.length);
assert.ok(rows <= subjects.length);
});
});
- a.close(); b.close();
- fs.rmSync(dir, { recursive: true, force: true });
+ await db.close();
done();
done();
})();
})();