openvibers/openvibe.community Commits

Community on PostgreSQL (ADR-035, roadmap WS-X2, repository pass G3).

OP OpenVibers committed 28 Sept 2026 · 8f021e6e7a · 97 files, +3080 −4108

Every table moves to ov_community on the host's data role. migrations/0001_initial.sql is the schema the SQLite
release's own boot left (openvibe-sdk tools gen-migration.js --open: SCHEMA, then every migrate() ALTER and seed,
tables in dependency order), plus the tables modules created at runtime (search_doc_pushes, account_data_events,
network_blocks, profile_module_pushes, token_revocations, the SDK outbox and inbox). Timestamps stay SQLite's
text: the migration defines ov_now(), datetime() and julianday() with SQLite's behaviour and ov_hot() for the hot
rank, so stored values, comparisons and API output are unchanged. space_chat_rooms.room_id is text (Chat's ids).

The store is async through openvibe-sdk/db (server/db.js initDb/getDb). Votes lock the target row (FOR UPDATE) in
place of SQLite's IMMEDIATE, so the recount sees every committed vote. Events go through createPgOutbox (enqueued
in the change's transaction, kicked after commit), Pulse's receipts through createPgInbox, Network's token cutoffs
through createPgRevocationStore (loaded before the app serves; SDK 0.21.0). The relay kicks its drain after commit
(an edit and the delete right after it still go out as one pass), and the Discord gateway handles messages one at a
time in arrival order. Per-actor limits count on Valkey when VALKEY_URL is set; /api/ready names the store and
reports Valkey. The one-time pastes, Live-comments and C-24 importers ran on SQLite and are retired (git history).
scripts/migrate-to-postgres.js is the import (rehearsed on a production copy: 36 tables, counts and checksums).

The N-1 test boots this release on its own database: the previous release's client calls pass; the N-1 SQL half is
skipped for this release (N-1 on SQLite shares no database with it), and from the next release it checks that every
migration N-1 ran is still here unchanged. CI runs npm test (PGlite) and test:pg (PostgreSQL + PgBouncer + Valkey).
npm test and test:pg: 45/46 each, 1 skipped (the N-1 SQL half).

Changes

ddiff --git a/.env.example b/.env.example
iindex e385dec..d5c48be 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 7b8372a..77d2e3a 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -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@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
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
ddiff --git a/README.md b/README.md
iindex 9418cd3..609a49e 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
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,
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 Network
has 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` (the
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`
(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 token
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
one 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 go
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
or 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/, T
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`).
--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`).
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
is 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, routes
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
identity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymous
identity/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, shapes
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/catalog.js recent public pastes: trending, related, language filter
comments/ 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
ddiff --git a/STATUS.json b/STATUS.json
iindex ee18dfb..4b6b256 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/deploy/systemd/openvibe-community.service b/deploy/systemd/openvibe-community.service
iindex 0b58eba..b64077b 100644
---- a/deploy/systemd/openvibe-community.service
++++ b/deploy/systemd/openvibe-community.service
@@@ -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=true
PrivateTmp=true
ProtectSystem=strict
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
Environment=COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..81bacd7
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex be49182..7651a23 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 8226c48..bea8f3c 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/scripts/import-live-comments.js b/scripts/import-live-comments.js
ddeleted file mode 100755
iindex bcb79eb..0000000
---- a/scripts/import-live-comments.js
++++ /dev/null
@@@ -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); });
ddiff --git a/scripts/import-pastes.js b/scripts/import-pastes.js
ddeleted file mode 100644
iindex 6f128f5..0000000
---- a/scripts/import-pastes.js
++++ /dev/null
@@@ -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); });
ddiff --git a/scripts/migrate-screenshot-refs.js b/scripts/migrate-screenshot-refs.js
ddeleted file mode 100644
iindex c5a6123..0000000
---- a/scripts/migrate-screenshot-refs.js
++++ /dev/null
@@@ -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); });
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100755
iindex 0000000..f9ea512
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -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 };
ddiff --git a/scripts/n-1-record.js b/scripts/n-1-record.js
iindex 6000d36..de4b851 100644
---- a/scripts/n-1-record.js
++++ b/scripts/n-1-record.js
@@@ -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`);
ddiff --git a/server/actor-limits.js b/server/actor-limits.js
iindex c077fc1..b25c495 100644
---- a/server/actor-limits.js
++++ b/server/actor-limits.js
@@@ -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 = n
limits: 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}`);
ddiff --git a/server/app.js b/server/app.js
iindex cc1ac05..f877a8d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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);
ddiff --git a/server/comments/api.js b/server/comments/api.js
iindex 81079aa..0f915c4 100644
---- a/server/comments/api.js
++++ b/server/comments/api.js
@@@ -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;
ddiff --git a/server/comments/live-import.js b/server/comments/live-import.js
ddeleted file mode 100644
iindex 53a78da..0000000
---- a/server/comments/live-import.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 1b7c4fb..d573fe5 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -115,15 +115,15 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
const 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, limits
if (!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_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');
}
@@@ -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);
/** 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, limits
SIGNED_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, limits
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`);
}
-- 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) };
},
};
}
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex 195c8cc..937bf31 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -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_id
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));
}
@@@ -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.created
return 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();
ddiff --git a/server/config.js b/server/config.js
iindex e0c26ba..2755d58 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)
ddiff --git a/server/db.js b/server/db.js
iindex 315a153..8b5aca0 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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);
--
---- Per-visitor view bookkeeping (cooldown + unique counts). Visitors are 'u:<subject>' or an
---- HMAC of the address ('ip:<hash>'); no raw addresses are stored.
--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)
--);
--
---- ── Typed comment threads (server/comments) ──────────────────
---- One thread per foreign entity (an EntityRef: service + type + id). Other products embed these
---- instead of owning comment tables. Paste comments stay in paste_comments.
--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)
--);
--
---- ── Forum: spaces / threads / posts (server/forum) ───────────
---- visibility: public (anyone reads, people post), members (signed-in people only), staff.
--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');
--
---- origin: who wrote it — a person (user), AI output (never attributed to a person), a relay
---- from another platform (discord: never relayed back out), or the site itself (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)
--);
--
---- Images attached to forum posts (WS-J task 2): the bytes are a Media object (med_…, tenant community,
---- owned by the person); an upload waits here with no post until the thread or reply naming it is saved.
--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;
--
---- The board index (forum style): spaces are listed under groups, like a vBulletin/SMF board index.
--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
--);
--
---- Facepunch-style ratings on posts: one per person per post (forum/reactions.js lists them).
--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);
--
---- Pastes attached to posts (a card with the paste's first lines, or its screenshot).
--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)
--);
--
---- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.
--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)
--);
--
---- A space's chat room on OpenVibe.Chat (WS-I task 4; server/chat-rooms.js), one per space. room_id and
---- room_slug are Chat's; name, kind and visibility are what Chat answered when it was attached (or last
---- re-attached). attached_by is the usr_ subject who attached it.
--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
--);
--
---- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
---- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
---- live in the database. migrate() adds discord_channel_id (the channel the webhook posts into;
---- learned from Discord's answer when unset), discord_thread_id (post into this Discord thread) and
---- inbound (replies from Discord come back as posts).
--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)
--);
--
---- One outbound action per dedupe key (WS-J task 6): send a thread's or a reply's Discord message
---- (create), edit it or delete it. post_id NULL: the thread's own message. source: queued by the
---- Events worker from community.thread.* / community.post.* (event_id), or directly by the forum.
---- status pending → delivered | failed (the dead letter: retries used up, or refused; staff retry or
---- drop it) | dropped (by staff) | skipped (nothing left to do, e.g. deleted before it was sent).
--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);
--
---- External message map (WS-J task 5): every Discord message the relay knows, both ways.
---- out a thread (local_type 'thread': title and opening post) or a reply sent through the mapping's webhook
---- in a reply written on Discord, now a post with origin 'discord' (local_type 'post')
---- external_channel_id is where the message is (a Discord thread's id for one in a thread);
---- external_thread_id is set when the relay sent it into a Discord thread (edits and deletes name it).
---- Unique both ways: a Discord message is one local object; a local object is one message per mapping.
---- Edits and deletes on either side find their counterpart here.
--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);
--
---- The relay's Events worker's place in OpenVibe.Events (server/relay/events-worker.js): the last seq
---- it handled, moved in the same transaction as the deliveries that page queued.
--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
--);
--
---- Messages from Discord that did not become (or change) a post, for staff (GET /api/v1/relay/inbound).
---- Ids and the reason only, never the text.
--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
--);
--
---- ── Pulse (server/pulse): public activity across the network, with provenance ──
---- Only public things enter; AI items carry origin 'ai' and never an actor.
--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);
---- The last games.progress.summary level seen per person (Pulse milestones, server/pulse/consumer.js).
--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 };
ddiff --git a/server/events.js b/server/events.js
iindex fe7a9e3..e231df3 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -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.oa
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 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 };
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 3d902bc..78f299e 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -60,35 +60,35 @@ function createSpacesApi({ forum, viewers, limits }) {
const configure = limits('community.space.configure', { minute: 30, hour: 300 }); // moderators' settings
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.
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;
}
ddiff --git a/server/forum/reactions.js b/server/forum/reactions.js
iindex 8073e41..bae87e4 100644
---- a/server/forum/reactions.js
++++ b/server/forum/reactions.js
@@@ -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_id
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) {
const def = BY_KEY.get(r.reaction);
ddiff --git a/server/forum/roadmap.js b/server/forum/roadmap.js
iindex fd66025..1ecfbd1 100644
---- a/server/forum/roadmap.js
++++ b/server/forum/roadmap.js
@@@ -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 };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 08c5b4e..e67b0ca 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -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); }
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex f32c0f8..f948c28 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, 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);
-- 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, vi
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. */
-- 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.content
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
ORDER BY pp.post_id, pp.position`).all(...postIds);
@@@ -290,9 +290,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
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');
-- 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, vi
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 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, 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,
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, vi
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.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, vi
isModerator: 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, vi
const 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, vi
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);
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, vi
if (!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, vi
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}`);
-- 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, vi
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(', ')}`);
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, vi
return 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, vi
const 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)))); },
};
}
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex e0e1285..4cede0a 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -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_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
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;
}
--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 posts
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 });
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_id
FROM 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 NULL
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 = [];
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.created
return 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 = {
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 41dde28..1252864 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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 };
ddiff --git a/server/identity/authors.js b/server/identity/authors.js
iindex 0a18058..80e7708 100644
---- a/server/identity/authors.js
++++ b/server/identity/authors.js
@@@ -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); }
}
/**
ddiff --git a/server/identity/blocks.js b/server/identity/blocks.js
iindex 73009e0..94914e0 100644
---- a/server/identity/blocks.js
++++ b/server/identity/blocks.js
@@@ -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_at
WHERE 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 };
ddiff --git a/server/identity/network.js b/server/identity/network.js
iindex 6c8fb9d..41c3d68 100644
---- a/server/identity/network.js
++++ b/server/identity/network.js
@@@ -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;
}
ddiff --git a/server/identity/profile-module.js b/server/identity/profile-module.js
iindex c8d563e..9aef5f9 100644
---- a/server/identity/profile-module.js
++++ b/server/identity/profile-module.js
@@@ -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 = c
changed('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 = c
function 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 = c
return { enabled, push, scan, start, stop, stats: () => ({ enabled, ...stats }) };
}
--module.exports = { NS, ensureSchema, summarize, createProfileModule };
++module.exports = { NS, summarize, createProfileModule };
ddiff --git a/server/identity/subject-merge.js b/server/identity/subject-merge.js
iindex d747605..b830cc9 100644
---- a/server/identity/subject-merge.js
++++ b/server/identity/subject-merge.js
@@@ -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';
}
ddiff --git a/server/identity/viewer.js b/server/identity/viewer.js
iindex e098ce8..9a5a449 100644
---- a/server/identity/viewer.js
++++ b/server/identity/viewer.js
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex 95ef91b..3aa4e1b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
});
ddiff --git a/server/live-client.js b/server/live-client.js
iindex c1082ed..489430f 100644
---- a/server/live-client.js
++++ b/server/live-client.js
@@@ -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) ────
ddiff --git a/server/media/files.js b/server/media/files.js
iindex fd499a1..9097c20 100644
---- a/server/media/files.js
++++ b/server/media/files.js
@@@ -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'}`);
ddiff --git a/server/media/objects.js b/server/media/objects.js
iindex 9d2bf8b..fdc2a97 100644
---- a/server/media/objects.js
++++ b/server/media/objects.js
@@@ -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'}`);
ddiff --git a/server/observability.js b/server/observability.js
iindex 020f8df..bfecf9b 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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 = nu
service: '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 };
}
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex 3ac8d88..b2cf979 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -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' }));
ddiff --git a/server/pastes/importer.js b/server/pastes/importer.js
ddeleted file mode 100644
iindex 023362a..0000000
---- a/server/pastes/importer.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 3205c28..d0d5829 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -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 = {}, lim
b.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 = {}, lim
is_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 = {}, lim
size_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 = {}, lim
is_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 = {}, lim
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 (!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 = {}, lim
visibility,
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 = {}, lim
const 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 = {}, lim
console.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) };
},
};
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 92e7aa2..e3bfabb 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -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 transaction
return 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.deleted
return 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_at
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);
-- 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 NULL
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');
-- 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));
}
ddiff --git a/server/pulse/api.js b/server/pulse/api.js
iindex 51c66b9..7a532c7 100644
---- a/server/pulse/api.js
++++ b/server/pulse/api.js
@@@ -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;
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex 8b77f36..953dff8 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -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) {
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
iindex 18a73fc..ea5bf35 100644
---- a/server/pulse/service.js
++++ b/server/pulse/service.js
@@@ -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 otherwise
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');
-- 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 };
ddiff --git a/server/pulse/store.js b/server/pulse/store.js
iindex a104ba6..65ae8ba 100644
---- a/server/pulse/store.js
++++ b/server/pulse/store.js
@@@ -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
ddiff --git a/server/relay/api.js b/server/relay/api.js
iindex ba13c9f..fd6739e 100644
---- a/server/relay/api.js
++++ b/server/relay/api.js
@@@ -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 };
}));
ddiff --git a/server/relay/discord-gateway.js b/server/relay/discord-gateway.js
iindex d8e2122..79aba4e 100644
---- a/server/relay/discord-gateway.js
++++ b/server/relay/discord-gateway.js
@@@ -110,6 +110,8 @@ function createDiscordGateway({ token = '', url = DEFAULT_URL, WebSocketImpl = g
sock.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) {
ddiff --git a/server/relay/discord.js b/server/relay/discord.js
iindex 9d8aaaa..b5d7088 100644
---- a/server/relay/discord.js
++++ b/server/relay/discord.js
@@@ -56,7 +56,7 @@ const DELIVERY_STATUSES = ['pending', 'delivered', 'failed', 'dropped', 'skipped
const 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 = gl
let 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 = gl
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`);
-- 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 = gl
threadTarget = (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 = gl
signal: 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 = gl
const 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_id
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()));
const paused = new Set();
@@@ -409,6 +418,8 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
stopped = 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 = gl
return 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 = gl
const 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_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
-- 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,
};
}
ddiff --git a/server/relay/events-worker.js b/server/relay/events-worker.js
iindex 053b19b..b6f82e2 100644
---- a/server/relay/events-worker.js
++++ b/server/relay/events-worker.js
@@@ -51,28 +51,28 @@ function createRelayEventsWorker({ db, relay, events = null, eventsUrl = null, c
let 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, c
running = (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, c
const 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, c
return 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,
ddiff --git a/server/relay/inbound.js b/server/relay/inbound.js
iindex 7116915..30d2714 100644
---- a/server/relay/inbound.js
++++ b/server/relay/inbound.js
@@@ -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 }) };
ddiff --git a/server/search/documents.js b/server/search/documents.js
iindex fe7166f..b2b5209 100644
---- a/server/search/documents.js
++++ b/server/search/documents.js
@@@ -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_slug
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);
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 gone
const 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 = []; }
ddiff --git a/server/seo.js b/server/seo.js
iindex b9a6901..07575fa 100644
---- a/server/seo.js
++++ b/server/seo.js
@@@ -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`;
}
ddiff --git a/server/votes.js b/server/votes.js
iindex dea22fa..300e3ab 100644
---- a/server/votes.js
++++ b/server/votes.js
@@@ -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 downvotes
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 };
-- }).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;
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 439aa33..23311ad 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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: 1
failNext = 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');
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 7295027..615be5d 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -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');
});
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
iindex 92a829a..772497e 100644
---- a/test/blocks.test.js
++++ b/test/blocks.test.js
@@@ -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 () => {
ddiff --git a/test/categories-roadmap.test.js b/test/categories-roadmap.test.js
iindex 4fa40e9..68c7fb0 100644
---- a/test/categories-roadmap.test.js
++++ b/test/categories-roadmap.test.js
@@@ -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 () => {
ddiff --git a/test/chat-room.test.js b/test/chat-room.test.js
iindex b18ee15..3d82228 100644
---- a/test/chat-room.test.js
++++ b/test/chat-room.test.js
@@@ -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');
ddiff --git a/test/comments.test.js b/test/comments.test.js
iindex e55e5a5..824295c 100644
---- a/test/comments.test.js
++++ b/test/comments.test.js
@@@ -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();
})();
ddiff --git a/test/events.test.js b/test/events.test.js
iindex 6bceb1c..2279778 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -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); });
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex 6453626..9721d59 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -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"');
ddiff --git a/test/forum-style.test.js b/test/forum-style.test.js
iindex cbcb799..e887518 100644
---- a/test/forum-style.test.js
++++ b/test/forum-style.test.js
@@@ -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');
});
ddiff --git a/test/forum.test.js b/test/forum.test.js
iindex c70e55d..d3a31ab 100644
---- a/test/forum.test.js
++++ b/test/forum.test.js
@@@ -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);
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 79fbf81..0cef15d 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -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); }
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..f6801a5
---- /dev/null
++++ b/test/helpers/db.js
@@@ -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 };
ddiff --git a/test/import-live-comments.test.js b/test/import-live-comments.test.js
ddeleted file mode 100644
iindex 90b68bf..0000000
---- a/test/import-live-comments.test.js
++++ /dev/null
@@@ -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();
--})();
ddiff --git a/test/import.test.js b/test/import.test.js
ddeleted file mode 100644
iindex 4dbff3c..0000000
---- a/test/import.test.js
++++ /dev/null
@@@ -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();
--})();
ddiff --git a/test/members-only.test.js b/test/members-only.test.js
iindex 8128078..6d432be 100644
---- a/test/members-only.test.js
++++ b/test/members-only.test.js
@@@ -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'));
});
ddiff --git a/test/migrate-screenshot-refs.test.js b/test/migrate-screenshot-refs.test.js
ddeleted file mode 100644
iindex 88ac5dc..0000000
---- a/test/migrate-screenshot-refs.test.js
++++ /dev/null
@@@ -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');
ddiff --git a/test/n-1.test.js b/test/n-1.test.js
iindex 78bbfe9..d832300 100644
---- a/test/n-1.test.js
++++ b/test/n-1.test.js
@@@ -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 });
ddiff --git a/test/n-1/boot-child.js b/test/n-1/boot-child.js
iindex 48b07a8..c0d0e19 100644
---- a/test/n-1/boot-child.js
++++ b/test/n-1/boot-child.js
@@@ -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); });
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex cfdcfb3..947ccd1 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -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 only
assert.strictEqual(typeof c.latency_ms, 'number');
assert.ok(Date.parse(c.checked_at));
}
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 6d4d710..775e2da 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -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();
ddiff --git a/test/pastes-feed-list.test.js b/test/pastes-feed-list.test.js
iindex 6eabec1..9116ca5 100644
---- a/test/pastes-feed-list.test.js
++++ b/test/pastes-feed-list.test.js
@@@ -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']);
ddiff --git a/test/profile-module.test.js b/test/profile-module.test.js
iindex 305ae15..3182dc0 100644
---- a/test/profile-module.test.js
++++ b/test/profile-module.test.js
@@@ -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, create
assert.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');
ddiff --git a/test/proxy.test.js b/test/proxy.test.js
iindex 8721a08..fc6c0a3 100644
---- a/test/proxy.test.js
++++ b/test/proxy.test.js
@@@ -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();
ddiff --git a/test/pulse-consumer.test.js b/test/pulse-consumer.test.js
iindex 8f8b9aa..6c58548 100644
---- a/test/pulse-consumer.test.js
++++ b/test/pulse-consumer.test.js
@@@ -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).p
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: [] } } }));
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).p
r = 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).p
const 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);
ddiff --git a/test/pulse.test.js b/test/pulse.test.js
iindex faddbdb..0d08218 100644
---- a/test/pulse.test.js
++++ b/test/pulse.test.js
@@@ -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']);
});
ddiff --git a/test/relay-events.test.js b/test/relay-events.test.js
iindex 86582d3..826b839 100644
---- a/test/relay-events.test.js
++++ b/test/relay-events.test.js
@@@ -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 returned
events.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 decides
events.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.crea
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}`] : [] });
-- 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.crea
await 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');
ddiff --git a/test/relay-inbound.test.js b/test/relay-inbound.test.js
iindex 650f3ff..a1a032d 100644
---- a/test/relay-inbound.test.js
++++ b/test/relay-inbound.test.js
@@@ -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 here
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']]);
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 });
ddiff --git a/test/relay.test.js b/test/relay.test.js
iindex 6180fe9..f5674d2 100644
---- a/test/relay.test.js
++++ b/test/relay.test.js
@@@ -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 away
await 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();
ddiff --git a/test/revocation.test.js b/test/revocation.test.js
iindex 9ac667e..a7cbe17 100644
---- a/test/revocation.test.js
++++ b/test/revocation.test.js
@@@ -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());
ddiff --git a/test/search-documents.test.js b/test/search-documents.test.js
iindex 6ca7ebb..9300149 100644
---- a/test/search-documents.test.js
++++ b/test/search-documents.test.js
@@@ -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); });
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 46669f2..c34aa95 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -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 } });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 01c5524..83118bb 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
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');
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}`);
});
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 3f98d0d..08eed8f 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -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, []);
});
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 2d1caf7..3913bff 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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: `![x](${urls[2]}) ${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');
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 7ce47dc..603ea86 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -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);
});
ddiff --git a/test/shutdown.test.js b/test/shutdown.test.js
iindex 63f3c4a..716cb95 100644
---- a/test/shutdown.test.js
++++ b/test/shutdown.test.js
@@@ -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',
ddiff --git a/test/ssr-community.test.js b/test/ssr-community.test.js
iindex 341b40d..5ef2e18 100644
---- a/test/ssr-community.test.js
++++ b/test/ssr-community.test.js
@@@ -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';
ddiff --git a/test/staff-map.test.js b/test/staff-map.test.js
iindex 6c70fdb..f383529 100644
---- a/test/staff-map.test.js
++++ b/test/staff-map.test.js
@@@ -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 () => {
ddiff --git a/test/store.test.js b/test/store.test.js
iindex 35e15e5..b686304 100644
---- a/test/store.test.js
++++ b/test/store.test.js
@@@ -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();
ddiff --git a/test/subject-merge.test.js b/test/subject-merge.test.js
iindex 49a4d49..b274424 100644
---- a/test/subject-merge.test.js
++++ b/test/subject-merge.test.js
@@@ -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, active
r = 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');
ddiff --git a/test/votes-race.test.js b/test/votes-race.test.js
iindex 95266bf..839f943 100644
---- a/test/votes-race.test.js
++++ b/test/votes-race.test.js
@@@ -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();
})();