openvibers/openvibe.blog Commits

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

OP OpenVibers committed 28 Sept 2026 · 8c8259f53c · 47 files, +880 −814

Schema: migrations/0001_initial.sql. It holds the charter tables and the changelog as they were, with epoch milliseconds as bigint, ISO times still text and 0/1 flags integer, so the API and the changelog cursor are unchanged. Then come openvibe-publishing 1.0's stores, the three charter views and the openvibe-sdk PostgreSQL outbox. Identifier columns are COLLATE "C". There are indexes for the published list and the changelog feed.

Code: the server was converted with a syntax-aware codemod (every call to a prepared statement, a Publishing store or a now-async module method is awaited, and the enclosing functions become async, to a fixpoint across files). Then by hand:
- the array callbacks it reports;
- batch reads: people in one query, revisions with getMany in list items, feeds, the sitemap and series (no N+1);
- the changelog's schema moved into the migration;
- SQL: ON CONFLICT for INSERT OR IGNORE/REPLACE, and GROUP BY for DISTINCT with ORDER BY;
- stamp() gets the handle;
- createApp is async (store open and migrate, official blog), and the server and seed await it;
- readiness names the store and Valkey, and per-actor limits count on Valkey when VALKEY_URL is set.

Import: scripts/migrate-to-postgres.js (openvibe-sdk runSqliteMigration). A database written by the current release imported and verified.

Tests: 25/25 on PGlite and 25/25 through PgBouncer (openvibe-sdk/testing createTestDb), and CI runs both. Pins: openvibe-sdk v0.19.0, openvibe-publishing v1.0.0, openvibe-contracts v0.76.0. The unit orders after PostgreSQL, PgBouncer and Valkey. README, STATUS.json and .env.example are updated.

Changes

ddiff --git a/.env.example b/.env.example
iindex 5ce9b96..298b277 100644
---- a/.env.example
++++ b/.env.example
@@@ -18,6 +18,14 @@ TRUST_PROXY=2
#BLOG_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh blog: the runtime role through PgBouncer,
++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++VALKEY_URL=
++VALKEY_PREFIX=
++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
BLOG_DB_PATH=./data/blog.db
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex d8ce26c..1e00c4b 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
contracts-service: blog
++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/README.md b/README.md
iindex 028008c..c769605 100644
---- a/README.md
++++ b/README.md
@@@ -25,7 +25,7 @@ It also uses:
## Owns
--The ten charter tables live in Blog's own SQLite (`BLOG_DB_PATH`). Where a publishing package
++The ten charter tables live in Blog's own PostgreSQL database (`ov_blog` on the host's data role, ADR-035; schema in [migrations/](migrations/)). Where a publishing package
applies, it creates the table with the `blog` prefix:
| Charter table | What it is |
@@@ -240,10 +240,12 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
## Depends on
--- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.4.0 (revisions, schedule,
-- taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`
-- v0.53.0, `openvibe-shared` v1.25.0 (chrome, app icon, footer, legal, release, metrics, ready,
-- theme presets), `openvibe-sdk` v0.12.0 (events outbox, service tokens, per-actor limits).
++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
++ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
++- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (async PostgreSQL stores: revisions,
++ schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`
++ v0.76.0, `openvibe-shared` v1.25.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
++ `openvibe-sdk` v0.19.0 (db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
- **OpenVibe.Network:**
- SSO: the OAuth client `blog` is already seeded with redirect
`https://openvibe.blog/auth/callback`.
@@@ -412,7 +414,11 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4810 (set OV_OAUTH
Production deploys with `sudo ovhost deploy blog` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.blog`, install on a lockfile change, restart, wait for `/api/ready`).
--The unit is `openvibe-blog.service` on `127.0.0.1:4810`, the env file `/etc/openvibe/blog.env`. nginx serves
++The unit is `openvibe-blog.service` on `127.0.0.1:4810`, the env file `/etc/openvibe/blog.env`. The database is
++`ov_blog` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh blog` 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-blog/blog.db` stays read-only for 7 days as the rollback. nginx serves
`openvibe.blog` from [deploy/nginx/openvibe.blog.conf](deploy/nginx/openvibe.blog.conf).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
restart; afterwards `sudo ovhost rollback blog --to <sha>`. Nothing blocks a rollback: the schema
ddiff --git a/STATUS.json b/STATUS.json
iindex 17e13ba..dbcd89e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,11 +4,16 @@
"code": true,
"wave": "W16",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "runtime": "node 22, express 4, better-sqlite3 11",
++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4810,
"domain": "openvibe.blog",
-- "contracts": "openvibe-contracts v0.53.0",
-- "packages": ["openvibe-publishing v0.4.0", "openvibe-contracts v0.53.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
++ "contracts": "openvibe-contracts v0.76.0",
++ "packages": [
++ "openvibe-publishing v1.0.0",
++ "openvibe-contracts v0.76.0",
++ "openvibe-shared v1.25.0",
++ "openvibe-sdk v0.19.0"
++ ],
"capabilities": [
"blog.blog.create",
"blog.blog.configure",
@@@ -47,5 +52,6 @@
"/api/v1 by capability",
"SDK outbox events and Search index documents",
"per-actor limits (openvibe-sdk/limits)"
-- ]
++ ],
++ "database": "PostgreSQL ov_blog (ADR-035), schema migrations/0001_initial.sql; the SQLite file of earlier releases is imported once by scripts/migrate-to-postgres.js and kept read-only 7 days"
}
ddiff --git a/deploy/systemd/openvibe-blog.service b/deploy/systemd/openvibe-blog.service
iindex e1a3bae..e4a3c27 100644
---- a/deploy/systemd/openvibe-blog.service
++++ b/deploy/systemd/openvibe-blog.service
@@@ -2,8 +2,8 @@
Description=OpenVibe.Blog — the official OpenVibe blog and a blog for every member
# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Blog
# degrades gracefully without them: pages and feeds serve, sign-in and events wait (see /api/ready).
--After=network.target
--Wants=openvibe-network.service
++After=network.target postgresql.service pgbouncer.service valkey-server.service
++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
Type=simple
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100644
iindex 0000000..24e9a68
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -0,0 +1,26 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The one-time move of Blog's SQLite database (BLOG_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; the three charter views are views on both sides and are not copied.
++ */
++require('dotenv').config();
++const { runSqliteMigration } = require('openvibe-sdk/db');
++const configLib = require('../server/config');
++const { MIGRATIONS } = require('../server/db');
++
++const TABLES = {};
++
++if (require.main === module) {
++ const config = configLib.load();
++ runSqliteMigration({ service: 'blog', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
++}
++
++module.exports = { TABLES };
ddiff --git a/scripts/seed.js b/scripts/seed.js
iindex e2998fe..e9fc116 100644
---- a/scripts/seed.js
++++ b/scripts/seed.js
@@@ -16,15 +16,17 @@ const { seedOfficialPost } = require('../server/seed');
const args = process.argv.slice(2);
const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
--const { ctx } = createApp();
--try {
-- const out = seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });
-- console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(ctx.blogs.get(out.post.blog_id), out.post)}`);
-- if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);
-- if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');
--} catch (err) {
-- console.error(`[seed] ${err.message}`);
-- process.exitCode = 1;
--} finally {
-- ctx.store.close();
--}
++(async () => {
++ const { ctx } = await createApp();
++ try {
++ const out = await seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });
++ console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(await ctx.blogs.get(out.post.blog_id), out.post)}`);
++ if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);
++ if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');
++ } catch (err) {
++ console.error(`[seed] ${err.message}`);
++ process.exitCode = 1;
++ } finally {
++ await ctx.store.close();
++ }
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 2cdd40a..c110abe 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -47,11 +47,12 @@ const VERSION = require('../package.json').version;
* entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log,
* limitsNow (the per-actor limiter's clock, tests)
*/
--function createApp(opts = {}) {
++async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
-- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
++ const store = opts.store || await openStore(config, { now: opts.now, log });
const outbox = createBlogOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const blogs = createBlogs({ store, config });
@@@ -67,7 +68,7 @@ function createApp(opts = {}) {
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config, people });
const worker = createWorker({ config, store, posts, media, outbox, log });
-- blogs.ensureOfficial();
++ await blogs.ensureOfficial();
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });
@@@ -82,7 +83,10 @@ function createApp(opts = {}) {
app.locals.ctx = ctx;
// Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, counted once each
// router resolved req.viewer; the per-address limits below stay.
-- ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
++ ctx.valkey = valkey;
++ ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use(contracts.http.middleware());
app.use(helmet({
@@@ -117,7 +121,7 @@ function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-blog', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
-- const readiness = createBlogReadiness({ store, auth, outbox, release: release.release });
++ const readiness = createBlogReadiness({ store, auth, outbox, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex 5385fa2..987ea19 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -66,7 +66,7 @@ function createViewerResolver({ auth, config, people }) {
const claims = await auth.verify(token);
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
-- if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
++ if (subject && people) { try { await people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
// Staff powers come from the contracts staff map (ADR-022): moderation on any blog, and the official blog.
return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.content.moderate'), editorial: staffMap.can(claims, 'staff.editorial.manage'), origin: 'user', user: claimsToUser(claims), token };
}
@@@ -79,7 +79,7 @@ function createViewerResolver({ auth, config, people }) {
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/changelog.js b/server/changelog.js
iindex 55f6bfb..44c79d7 100644
---- a/server/changelog.js
++++ b/server/changelog.js
@@@ -31,50 +31,8 @@ const { serviceAuth } = require('openvibe-contracts');
const GITHUB = 'https://api.github.com';
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
--function ensureSchema(db) {
-- db.exec(`
-- CREATE TABLE IF NOT EXISTS changelog_state (
-- service TEXT PRIMARY KEY,
-- release TEXT NOT NULL,
-- head TEXT,
-- checked_at INTEGER NOT NULL
-- );
-- CREATE TABLE IF NOT EXISTS changelog_entries (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- service TEXT NOT NULL,
-- repo TEXT NOT NULL,
-- sha TEXT NOT NULL,
-- subject TEXT NOT NULL,
-- committed_at TEXT,
-- deployed_at TEXT NOT NULL,
-- release TEXT NOT NULL,
-- release_lines INTEGER NOT NULL DEFAULT 0,
-- major INTEGER NOT NULL DEFAULT 0,
-- post_id TEXT,
-- UNIQUE (service, sha)
-- );
-- CREATE INDEX IF NOT EXISTS idx_changelog_pending ON changelog_entries(post_id, deployed_at);
-- CREATE TABLE IF NOT EXISTS changelog_posts (
-- post_id TEXT PRIMARY KEY,
-- blog_id TEXT NOT NULL,
-- entries INTEGER NOT NULL,
-- from_at TEXT,
-- to_at TEXT,
-- reason TEXT NOT NULL,
-- ai_draft_id TEXT,
-- created_at INTEGER NOT NULL
-- );
-- CREATE TABLE IF NOT EXISTS changelog_history (
-- service TEXT PRIMARY KEY,
-- imported INTEGER NOT NULL,
-- imported_at INTEGER NOT NULL
-- );
-- CREATE INDEX IF NOT EXISTS idx_changelog_service ON changelog_entries(service, deployed_at);
-- `);
-- // Added after the first release: who wrote each change (the commit's author name).
-- const cols = db.prepare('PRAGMA table_info(changelog_entries)').all().map((c) => c.name);
-- if (!cols.includes('author')) db.exec('ALTER TABLE changelog_entries ADD COLUMN author TEXT');
--}
++/** The changelog tables are in migrations/0001_initial.sql (PostgreSQL, ADR-035); kept for callers of the old helper. */
++async function ensureSchema() {}
/** The feed's page cursor: "<deployed_at>|<id>" of the last entry shown. */
function encodeCursor(e) { return Buffer.from(`${e.deployed_at}|${e.id}`).toString('base64url'); }
@@@ -98,7 +56,6 @@ const isNoise = (subject) => /^Merge (branch|pull request|remote)/i.test(subject
function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImpl = globalThis.fetch, log = console }) {
const c = config.changelog;
const db = store.db;
-- ensureSchema(db);
const nowMs = () => store.now();
const stats = { runs: 0, entries: 0, posts: 0, lastError: null, lastRunAt: null };
@@@ -106,11 +63,11 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
state: db.prepare('SELECT * FROM changelog_state WHERE service = ?'),
putState: db.prepare(`INSERT INTO changelog_state (service, release, head, checked_at) VALUES (@service, @release, @head, @at)
ON CONFLICT(service) DO UPDATE SET release = excluded.release, head = excluded.head, checked_at = excluded.checked_at`),
-- insert: db.prepare(`INSERT OR IGNORE INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id)
-- VALUES (@service, @repo, @sha, @subject, @author, @committed_at, @deployed_at, @release, @release_lines, @major, @post_id)`),
++ insert: db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id)
++ VALUES (@service, @repo, @sha, @subject, @author, @committed_at, @deployed_at, @release, @release_lines, @major, @post_id) ON CONFLICT (service, sha) DO NOTHING`),
fillAuthor: db.prepare('UPDATE changelog_entries SET author = ? WHERE service = ? AND sha = ? AND author IS NULL'),
history: db.prepare('SELECT * FROM changelog_history WHERE service = ?'),
-- putHistory: db.prepare('INSERT OR REPLACE INTO changelog_history (service, imported, imported_at) VALUES (?, ?, ?)'),
++ putHistory: db.prepare('INSERT INTO changelog_history (service, imported, imported_at) VALUES (?, ?, ?) ON CONFLICT (service) DO UPDATE SET imported = excluded.imported, imported_at = excluded.imported_at'),
recentPosts: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT ?'),
sites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),
pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),
@@@ -184,7 +141,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
// history: the commits a site shipped before the changelog watched it, so its updates page is not
// empty on day one. They carry post_id 'history' (never pending, never in a patch notes post) and
// their commit time stands in for the deploy time nobody recorded.
-- function addEntries(svc, commits, { releaseLines = 0, major = false, history = false } = {}) {
++ async function addEntries(svc, commits, { releaseLines = 0, major = false, history = false } = {}) {
const deployedAt = svc.releasedAt || new Date(nowMs()).toISOString();
let n = 0;
for (const cm of commits) {
@@@ -194,12 +151,12 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
const committedAt = (cm.commit && cm.commit.author && cm.commit.author.date) || null;
if (history && !committedAt) continue;
const author = authorOf(cm);
-- const added = q.insert.run({
++ const added = (await q.insert.run({
service: svc.id, repo: svc.repo, sha: cm.sha, subject, author, committed_at: committedAt,
deployed_at: history ? new Date(committedAt).toISOString() : deployedAt, release: svc.release, release_lines: releaseLines, major: flagged ? 1 : 0,
post_id: history ? 'history' : null,
-- }).changes;
-- if (!added && author) q.fillAuthor.run(author, svc.id, cm.sha);
++ })).changes;
++ if (!added && author) await q.fillAuthor.run(author, svc.id, cm.sha);
n += added;
}
return n;
@@@ -207,36 +164,36 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
/** Once per service: its last c.history commits on the deployed release. */
async function importHistory(svc) {
-- if (!(c.history > 0) || q.history.get(svc.id)) return 0;
++ if (!(c.history > 0) || await q.history.get(svc.id)) return 0;
let list;
try { list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&per_page=${Math.min(c.history, 100)}`); } catch (err) {
// A repository GitHub will not list (private, empty, gone) is not asked again; anything else is.
-- if ([404, 409, 422].includes(err.status)) { q.putHistory.run(svc.id, 0, nowMs()); return 0; }
++ if ([404, 409, 422].includes(err.status)) { await q.putHistory.run(svc.id, 0, nowMs()); return 0; }
throw err;
}
-- const n = addEntries(svc, Array.isArray(list) ? list : [], { history: true });
-- q.putHistory.run(svc.id, n, nowMs());
++ const n = await addEntries(svc, Array.isArray(list) ? list : [], { history: true });
++ await q.putHistory.run(svc.id, n, nowMs());
return n;
}
async function collectOne(svc) {
-- const st = q.state.get(svc.id);
++ const st = await q.state.get(svc.id);
if (st && st.release === svc.release) return 0;
if (!st) {
let n = 0;
if (c.since) {
const list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&since=${encodeURIComponent(c.since)}&per_page=100`);
-- n = addEntries(svc, (Array.isArray(list) ? list : []).slice().reverse());
++ n = await addEntries(svc, (Array.isArray(list) ? list : []).slice().reverse());
}
-- q.putState.run({ service: svc.id, release: svc.release, head: null, at: nowMs() });
++ await q.putState.run({ service: svc.id, release: svc.release, head: null, at: nowMs() });
return n;
}
const cmp = await gh(`/repos/${svc.repo}/compare/${st.head || st.release}...${svc.release}`);
const commits = Array.isArray(cmp.commits) ? cmp.commits : [];
const lines = (Array.isArray(cmp.files) ? cmp.files : []).reduce((a, f) => a + (Number(f.additions) || 0) + (Number(f.deletions) || 0), 0);
-- const n = cmp.status === 'behind' ? 0 : addEntries(svc, commits, { releaseLines: lines, major: lines >= c.majorLines });
++ const n = cmp.status === 'behind' ? 0 : await addEntries(svc, commits, { releaseLines: lines, major: lines >= c.majorLines });
const head = commits.length ? commits[commits.length - 1].sha : st.head;
-- q.putState.run({ service: svc.id, release: svc.release, head, at: nowMs() });
++ await q.putState.run({ service: svc.id, release: svc.release, head, at: nowMs() });
return n;
}
@@@ -247,7 +204,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
try {
added += await collectOne(svc);
// A few history imports per run keep an unauthenticated GitHub budget intact.
-- if (histories < 6 && !q.history.get(svc.id)) { histories++; await importHistory(svc); }
++ if (histories < 6 && !await q.history.get(svc.id)) { histories++; await importHistory(svc); }
} catch (err) {
stats.lastError = `${svc.id}: ${err.message}`;
if (err.status === 403 || err.status === 429) break; // GitHub's rate limit: try again next run
@@@ -258,9 +215,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
}
/** { due, reason } for the pending entries. */
-- function due(list, at = nowMs()) {
++ async function due(list, at = nowMs()) {
if (!list.length) return { due: false, reason: 'nothing pending' };
-- const last = q.lastPost.get();
++ const last = await q.lastPost.get();
const sinceLast = last ? at - last.created_at : Infinity;
const newest = Math.max(...list.map((e) => Date.parse(e.deployed_at) || 0));
const oldest = Math.min(...list.map((e) => Date.parse(e.deployed_at) || at));
@@@ -307,19 +264,19 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
}
async function publish(list, reason) {
-- const blog = blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? blogs.ensureOfficial() : null);
++ const blog = await blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? await blogs.ensureOfficial() : null);
const owner = config.official.owners.find((s) => /^usr_/.test(s));
if (!blog || !owner) { stats.lastError = !blog ? `no blog @${c.blogHandle}` : 'BLOG_OFFICIAL_OWNERS names no owner to publish as'; return null; }
const services = new Map((await registry().catch(() => [])).map((s) => [s.id, s]));
for (const e of list) if (!services.has(e.service)) services.set(e.service, { id: e.service, name: e.service, repo: e.repo, origin: null });
const doc = render(list, services);
const viewer = { kind: 'user', subject: owner, staff: false };
-- const { post } = posts.create(viewer, blog, {
++ const { post } = await posts.create(viewer, blog, {
title: doc.title, summary: doc.summary, body: doc.body, tags: doc.tags.filter((t) => t.length <= 50).slice(0, 20), series: 'Patch notes',
authorship: { mode: 'imported', importedFrom: { label: 'Commit messages from the OpenVibers repositories on GitHub', originalAuthor: 'OpenVibers' } },
message: `Patch notes from ${list.length} commits (${reason})`,
});
-- posts.publish(viewer, posts.get ? posts.get(post.id) || post : post);
++ await posts.publish(viewer, posts.get ? await posts.get(post.id) || post : post);
let aiDraftId = null;
if (c.aiDraft && aiDrafts && aiDrafts.enabled) {
try {
@@@ -328,9 +285,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
aiDraftId = d.post.id;
} catch (err) { log.warn && log.warn(`[changelog] AI dev-blog draft not made: ${err.message}`); }
}
-- store.tx(() => {
-- for (const e of list) q.mark.run(post.id, e.id);
-- q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });
++ await store.tx(async () => {
++ for (const e of list) await q.mark.run(post.id, e.id);
++ await q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });
});
stats.posts++;
log.log && log.log(`[changelog] published ${post.id} (${list.length} entries, ${reason})`);
@@@ -341,8 +298,8 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
try {
await collect();
-- const list = q.pending.all();
-- const d = due(list);
++ const list = await q.pending.all();
++ const d = await due(list);
if (d.due) return await publish(list.slice(0, c.batchSize * 3), d.reason);
return null;
} catch (err) { stats.lastError = err.message; log.warn && log.warn(`[changelog] ${err.message}`); return null; }
@@@ -359,14 +316,14 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
function stop() { if (timer) clearInterval(timer); timer = null; }
/** The public feed, newest first: { entries, next } (next = the cursor for the page after, or null). */
-- function page({ service = null, limit = 20, before = null } = {}) {
++ async function page({ service = null, limit = 20, before = null } = {}) {
const n = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
const cur = decodeCursor(before);
const where = [];
const args = [];
if (service) { where.push('service = ?'); args.push(String(service)); }
if (cur) { where.push('(deployed_at < ? OR (deployed_at = ? AND id < ?))'); args.push(cur.at, cur.at, cur.id); }
-- const rows = db.prepare(`SELECT * FROM changelog_entries ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY deployed_at DESC, id DESC LIMIT ?`).all(...args, n + 1);
++ const rows = await db.prepare(`SELECT * FROM changelog_entries ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY deployed_at DESC, id DESC LIMIT ?`).all(...args, n + 1);
const more = rows.length > n;
const shown = rows.slice(0, n);
return {
@@@ -377,13 +334,13 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
next: more && shown.length ? encodeCursor(shown[shown.length - 1]) : null,
};
}
-- function entries(opts = {}) { return page(opts).entries; }
-- function latestPost() { return q.lastPost.get() || null; }
-- function recentPosts(limit = 5) { return q.recentPosts.all(Math.min(Math.max(parseInt(limit, 10) || 5, 1), 20)); }
++ async function entries(opts = {}) { return (await page(opts)).entries; }
++ async function latestPost() { return await q.lastPost.get() || null; }
++ async function recentPosts(limit = 5) { return await q.recentPosts.all(Math.min(Math.max(parseInt(limit, 10) || 5, 1), 20)); }
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
-- function sites() { return q.sites.all(); }
++ async function sites() { return await q.sites.all(); }
-- return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: q.pending.all().length }) };
++ return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
}
/** A stable id for a batch (tests). */
ddiff --git a/server/clients/community.js b/server/clients/community.js
iindex 855a7e8..2e1502a 100644
---- a/server/clients/community.js
++++ b/server/clients/community.js
@@@ -54,7 +54,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
/** The stored thread id, or resolve it once through Community. Public posts only. */
async threadFor(post, label, ctx) {
if (post.visibility !== 'public') return null;
-- const known = store.discussion.get(post.id);
++ const known = await store.discussion.get(post.id);
if (known) return known.threadId;
if (!discussion) return null;
const out = await store.discussion.threadFor(post.id, refFor(post, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
@@@ -64,13 +64,13 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
/** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
async readThread(threadId, { after, ctx } = {}) {
const qs = after ? `?after=${encodeURIComponent(after)}` : '';
-- return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
++ return await call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
},
/** Comment as the signed-in member. */
async comment(threadId, subject, { message, parentId } = {}, ctx) {
if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
-- return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
++ return await call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
});
},
@@@ -80,7 +80,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
* again when it is public again. Needs community.comment.moderate.
*/
async setThreadVisibility(post, visibility, ctx) {
-- const known = store.discussion.get(post.id);
++ const known = await store.discussion.get(post.id);
if (!known || !modTokens) return false;
try {
await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
ddiff --git a/server/clients/network.js b/server/clients/network.js
iindex 00b977e..297b003 100644
---- a/server/clients/network.js
++++ b/server/clients/network.js
@@@ -23,6 +23,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
}) : null;
const q = {
get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),
++ getMany: db.prepare('SELECT * FROM subject_projections WHERE subject = ANY(?)'),
byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),
put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
@@@ -30,9 +31,9 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
};
const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
-- function remember(subject, p) {
++ async function remember(subject, p) {
if (!ids.isSubjectId('user', subject) || !p) return;
-- q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
++ await q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
}
async function resolve(subjects) {
@@@ -46,7 +47,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
if (res.status === 401) tokens.invalidate && tokens.invalidate();
const data = await res.json().catch(() => null);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);
-- for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);
++ for (const s of subjects) if (data.results[s]) await remember(s, data.results[s]);
}
const shape = (row, subject) => (row
@@@ -56,12 +57,14 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
return {
enabled,
/** From a verified sign-in: the member's own claims are a fresh projection. */
-- rememberClaims(subject, claims) { remember(subject, claims); },
++ async rememberClaims(subject, claims) { await remember(subject, claims); },
/** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */
async many(subjects) {
const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];
-- const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
++ const read = async () => new Map((list.length ? await q.getMany.all(list) : []).map((r) => [r.subject, r]));
++ let rows = await read();
++ const missing = list.filter((s) => { const r = rows.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
if (missing.length && tokens) {
let timer;
await Promise.race([
@@@ -69,19 +72,20 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),
]);
clearTimeout(timer);
++ rows = await read();
}
-- return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));
++ return new Map(list.map((s) => [s, shape(rows.get(s) || null, s)]));
},
async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },
/** The subject a username belongs to, from people Blog has seen. */
-- byUsername(username) {
-- const row = q.byUsername.get(String(username || '').toLowerCase());
++ async byUsername(username) {
++ const row = await q.byUsername.get(String(username || '').toLowerCase());
return row ? shape(row, row.subject) : null;
},
-- cached: (subject) => shape(q.get.get(subject), subject),
++ cached: async (subject) => shape(await q.get.get(subject), subject),
};
}
ddiff --git a/server/domain/access.js b/server/domain/access.js
iindex 6b2d5e0..6be4fc9 100644
---- a/server/domain/access.js
++++ b/server/domain/access.js
@@@ -30,15 +30,15 @@
const ROLE_RANK = { author: 1, editor: 2, owner: 3 };
--function roleOf(store, blogId, subject) {
++async function roleOf(store, blogId, subject) {
if (!subject) return null;
-- const row = store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);
++ const row = await store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);
return row ? row.role : null;
}
/** Effective role of the viewer on a blog, staff included ('owner' of the official blog with staff.editorial.manage). */
--function effectiveRole(store, viewer, blog) {
-- const role = roleOf(store, blog.id, viewer && viewer.subject);
++async function effectiveRole(store, viewer, blog) {
++ const role = await roleOf(store, blog.id, viewer && viewer.subject);
if (viewer && viewer.editorial && blog.kind === 'official') return 'owner';
return role;
}
@@@ -48,8 +48,8 @@ const atLeast = (role, min) => Boolean(role) && ROLE_RANK[role] >= ROLE_RANK[min
function isStaff(viewer) { return Boolean(viewer && viewer.staff); }
/** Is this viewer a member of the blog who may see this (unpublished or restricted) post? */
--function memberCanSee(store, viewer, blog, post) {
-- const role = effectiveRole(store, viewer, blog);
++async function memberCanSee(store, viewer, blog, post) {
++ const role = await effectiveRole(store, viewer, blog);
if (!role) return false;
if (atLeast(role, 'editor')) return true;
return post.author_subject === viewer.subject;
@@@ -103,7 +103,7 @@ function createEntitlementChecker({ provider = 'none', check = null, vip = null
async function canReadPost(store, viewer, blog, post, entitlements) {
if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };
if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };
-- const member = memberCanSee(store, viewer, blog, post) || isStaff(viewer);
++ const member = await memberCanSee(store, viewer, blog, post) || isStaff(viewer);
if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };
if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };
if (member) return { allowed: true, status: 200, reason: 'member' };
@@@ -115,10 +115,10 @@ async function canReadPost(store, viewer, blog, post, entitlements) {
}
/** Write decisions. action ∈ create | edit | publish | delete | configure | members. */
--function canWrite(store, viewer, blog, action, post = null) {
++async function canWrite(store, viewer, blog, action, post = null) {
if (!viewer || !viewer.subject) return false;
if (blog.status !== 'active' && !isStaff(viewer)) return false;
-- const role = effectiveRole(store, viewer, blog);
++ const role = await effectiveRole(store, viewer, blog);
switch (action) {
case 'create': return atLeast(role, 'author');
case 'edit':
ddiff --git a/server/domain/ai-drafts.js b/server/domain/ai-drafts.js
iindex 468d822..9bbefde 100644
---- a/server/domain/ai-drafts.js
++++ b/server/domain/ai-drafts.js
@@@ -25,7 +25,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
async function draft(viewer, blog, input = {}, { traceparent } = {}) {
if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to ask for an AI draft');
-- if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
++ if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
const topic = clean(input.topic, 300);
if (!topic) throw new ApiError(422, 'ai_draft.topic_required', 'Say what the post should be about');
const brief = String(input.brief == null ? '' : input.brief).trim().slice(0, 4000);
@@@ -49,7 +49,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}));
// The member acts (their blog, their draft); the text is the AI's (authorship mode 'ai').
const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: viewer.subject };
-- return posts.create(aiViewer, blog, {
++ return await posts.create(aiViewer, blog, {
title: clean(out.title, 200) || topic,
summary: clean(out.dek, 400) || null,
body,
ddiff --git a/server/domain/blogs.js b/server/domain/blogs.js
iindex a454f7e..184ba38 100644
---- a/server/domain/blogs.js
++++ b/server/domain/blogs.js
@@@ -39,7 +39,7 @@ function createBlogs({ store, config }) {
official: db.prepare("SELECT * FROM blogs WHERE kind = 'official'"),
insert: db.prepare(`INSERT INTO blogs (id, kind, handle, owner_subject, title, description, theme, language, created_at, updated_at)
VALUES (@id, @kind, @handle, @owner_subject, @title, @description, @theme, @language, @now, @now)`),
-- feedDefaults: db.prepare('INSERT OR IGNORE INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?)'),
++ feedDefaults: db.prepare('INSERT INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?) ON CONFLICT DO NOTHING'),
member: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? AND subject = ?'),
members: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? ORDER BY CASE role WHEN \'owner\' THEN 0 WHEN \'editor\' THEN 1 ELSE 2 END, created_at'),
putMember: db.prepare(`INSERT INTO blog_memberships (blog_id, subject, role, added_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
@@@ -53,59 +53,59 @@ function createBlogs({ store, config }) {
seriesById: db.prepare('SELECT * FROM blog_series WHERE id = ?'),
};
-- function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {
++ async function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {
const now = store.now();
const row = { id: newBlogId(now), kind, handle, owner_subject: owner || null, title, description, theme, language, now };
-- q.insert.run(row);
-- q.feedDefaults.run(row.id, now);
-- if (owner) q.putMember.run(row.id, owner, 'owner', owner, now, now);
-- return q.byId.get(row.id);
++ await q.insert.run(row);
++ await q.feedDefaults.run(row.id, now);
++ if (owner) await q.putMember.run(row.id, owner, 'owner', owner, now, now);
++ return await q.byId.get(row.id);
}
const api = {
THEMES, THEME_PRESETS, ROLES, validHandle,
-- get: (id) => q.byId.get(id) || null,
-- byHandle: (handle) => q.byHandle.get(String(handle || '').toLowerCase()) || null,
-- ownedBy: (subject) => (subject ? q.byOwner.get(subject) || null : null),
-- blogsOf: (subject) => (subject ? q.blogsOf.all(subject) : []),
++ get: async (id) => await q.byId.get(id) || null,
++ byHandle: async (handle) => await q.byHandle.get(String(handle || '').toLowerCase()) || null,
++ ownedBy: async (subject) => (subject ? await q.byOwner.get(subject) || null : null),
++ blogsOf: async (subject) => (subject ? await q.blogsOf.all(subject) : []),
/** The official blog, created on first boot; config owners become owner memberships. */
-- ensureOfficial() {
-- return store.tx(() => {
-- let blog = q.official.get();
++ async ensureOfficial() {
++ return await store.tx(async () => {
++ let blog = await q.official.get();
if (!blog) {
-- blog = create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });
++ blog = await create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });
}
const now = store.now();
for (const s of config.official.owners) {
if (!ids.isSubjectId('user', s)) { console.warn(`[Blog] BLOG_OFFICIAL_OWNERS: ignoring "${s}" (not a usr_ subject)`); continue; }
-- if (!q.member.get(blog.id, s)) q.putMember.run(blog.id, s, 'owner', 'config', now, now);
++ if (!await q.member.get(blog.id, s)) await q.putMember.run(blog.id, s, 'owner', 'config', now, now);
}
return blog;
});
},
-- official: () => q.official.get() || null,
++ official: async () => await q.official.get() || null,
/**
* The member's own blog, created on first use. Handle: the requested one, else the Network
* username. A taken or reserved handle is a 409 the member resolves by choosing another.
*/
-- ensureMemberBlog({ subject, username, handle, title, description } = {}) {
++ async ensureMemberBlog({ subject, username, handle, title, description } = {}) {
if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'subject.required', 'Only a signed-in member (usr_ subject) can own a blog');
-- return store.tx(() => {
-- const existing = q.byOwner.get(subject);
++ return await store.tx(async () => {
++ const existing = await q.byOwner.get(subject);
if (existing) return { blog: existing, created: false };
const h = String(handle || username || '').toLowerCase();
if (!validHandle(h)) throw new ApiError(422, 'blog.invalid_handle', `"${h}" cannot be a blog handle (2–40 of a–z, 0–9, _ and -, not a reserved word)`);
-- if (q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);
-- const blog = create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });
++ if (await q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);
++ const blog = await create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });
return { blog, created: true };
});
},
-- update(blog, { title, description, language } = {}) {
++ async update(blog, { title, description, language } = {}) {
const sets = [];
const vals = [];
if (title != null) { const t = String(title).trim().slice(0, 120); if (!t) throw new ApiError(422, 'blog.invalid_title', 'A blog needs a title'); sets.push('title = ?'); vals.push(t); }
@@@ -115,77 +115,77 @@ function createBlogs({ store, config }) {
sets.push('language = ?'); vals.push(String(language));
}
if (!sets.length) return blog;
-- db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);
-- return q.byId.get(blog.id);
++ await db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);
++ return await q.byId.get(blog.id);
},
-- setTheme(blog, theme) {
++ async setTheme(blog, theme) {
if (!THEMES[theme]) throw new ApiError(422, 'blog.invalid_theme', `theme must be one of ${THEME_PRESETS.join(', ')}`);
-- db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);
-- return q.byId.get(blog.id);
++ await db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);
++ return await q.byId.get(blog.id);
},
/** CSS custom properties of the blog's preset, for its content surface. */
themeVars(blog) { return (THEMES[blog.theme] || THEMES.vibe).variables; },
-- feedSettings(blog) { return q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },
++ async feedSettings(blog) { return await q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },
-- setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {
-- const cur = api.feedSettings(blog);
++ async setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {
++ const cur = await api.feedSettings(blog);
const b = (v, d) => (v == null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);
const n = itemCount == null ? cur.item_count : parseInt(itemCount, 10);
if (!Number.isInteger(n) || n < 1 || n > 50) throw new ApiError(422, 'feed.invalid_item_count', 'item count must be between 1 and 50');
-- db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
++ await db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (blog_id) DO UPDATE SET rss = excluded.rss, atom = excluded.atom, json = excluded.json,
item_count = excluded.item_count, full_content = excluded.full_content, updated_at = excluded.updated_at`)
.run(blog.id, b(rss, cur.rss), b(atom, cur.atom), b(json, cur.json), n, b(fullContent, cur.full_content), store.now());
-- return api.feedSettings(blog);
++ return await api.feedSettings(blog);
},
-- members: (blog) => q.members.all(blog.id),
-- membership: (blog, subject) => q.member.get(blog.id, subject) || null,
++ members: async (blog) => await q.members.all(blog.id),
++ membership: async (blog, subject) => await q.member.get(blog.id, subject) || null,
-- setMember(blog, subject, role, addedBy) {
++ async setMember(blog, subject, role, addedBy) {
if (!ids.isSubjectId('user', subject)) throw new ApiError(422, 'member.invalid_subject', 'members are Network user subjects (usr_…)');
if (!ROLES.includes(role)) throw new ApiError(422, 'member.invalid_role', `role must be one of ${ROLES.join(', ')}`);
-- return store.tx(() => {
-- const cur = q.member.get(blog.id, subject);
-- if (cur && cur.role === 'owner' && role !== 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') {
++ return await store.tx(async () => {
++ const cur = await q.member.get(blog.id, subject);
++ if (cur && cur.role === 'owner' && role !== 'owner' && (await q.ownerCount.get(blog.id)).n <= 1 && blog.kind === 'member') {
throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
}
const now = store.now();
-- q.putMember.run(blog.id, subject, role, addedBy || null, now, now);
-- return q.member.get(blog.id, subject);
++ await q.putMember.run(blog.id, subject, role, addedBy || null, now, now);
++ return await q.member.get(blog.id, subject);
});
},
-- removeMember(blog, subject) {
-- return store.tx(() => {
-- const cur = q.member.get(blog.id, subject);
++ async removeMember(blog, subject) {
++ return await store.tx(async () => {
++ const cur = await q.member.get(blog.id, subject);
if (!cur) return false;
-- if (cur.role === 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
-- q.dropMember.run(blog.id, subject);
++ if (cur.role === 'owner' && (await q.ownerCount.get(blog.id)).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
++ await q.dropMember.run(blog.id, subject);
return true;
});
},
-- series: (blog) => q.series.all(blog.id),
-- seriesBySlug: (blog, slug) => q.seriesBySlug.get(blog.id, slug) || null,
-- seriesById: (id) => (id ? q.seriesById.get(id) || null : null),
++ series: async (blog) => await q.series.all(blog.id),
++ seriesBySlug: async (blog, slug) => await q.seriesBySlug.get(blog.id, slug) || null,
++ seriesById: async (id) => (id ? await q.seriesById.get(id) || null : null),
/** Get-or-create a series by title. */
-- ensureSeries(blog, title, description = null) {
++ async ensureSeries(blog, title, description = null) {
const t = String(title || '').trim().slice(0, 120);
if (!t) throw new ApiError(422, 'series.invalid_title', 'A series needs a title');
const slug = slugify(t);
-- return store.tx(() => {
-- const cur = q.seriesBySlug.get(blog.id, slug);
++ return await store.tx(async () => {
++ const cur = await q.seriesBySlug.get(blog.id, slug);
if (cur) return cur;
const now = store.now();
const id = newSeriesId(now);
-- db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
++ await db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, blog.id, slug, t, description, now, now);
-- return q.seriesById.get(id);
++ return await q.seriesById.get(id);
});
},
ddiff --git a/server/domain/effects.js b/server/domain/effects.js
iindex a09ad8a..69cd8ac 100644
---- a/server/domain/effects.js
++++ b/server/domain/effects.js
@@@ -9,8 +9,8 @@
*/
function createEffects({ outbox, community }) {
return {
-- after(before, post, ctx) {
-- outbox.kick();
++ async after(before, post, ctx) {
++ await outbox.kick();
if (!post || !before) return;
// Public = readable by everyone: published and public. Unpublishing hides the thread too.
const wasPublic = before.visibility === 'public' && before.state === 'published';
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex dfd091b..e6112d8 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -52,11 +52,11 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
VALUES (@id, @blog_id, @slug, 'draft', @visibility, @entitlement_key, @author_subject, @series_id, @series_position,
@allow_comments, @noindex, @now, @now)`),
};
-- const blogOf = (post) => blogs.get(post.blog_id);
++ const blogOf = async (post) => await blogs.get(post.blog_id);
// Staff acting where only their staff powers allow it (not a role on the blog, not their own post)
// is moderation: it also goes to Network's moderation audit log (ADR-022).
-- const byStaff = (viewer, blog, action, post) => access.isStaff(viewer) && !access.canWrite(store, { ...viewer, staff: false }, blog, action, post);
-- const moderated = (action, post, viewer, ctx, details) => outbox.moderationAction({
++ const byStaff = async (viewer, blog, action, post) => access.isStaff(viewer) && !await access.canWrite(store, { ...viewer, staff: false }, blog, action, post);
++ const moderated = async (action, post, viewer, ctx, details) => await outbox.moderationAction({
action, target: { type: 'post', id: post.id, owner_subject: post.author_subject }, actorSubject: viewer.subject, details: { blog_id: post.blog_id, ...details },
}, { traceparent: ctx.traceparent });
@@@ -86,13 +86,13 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
if (!KEY_RE.test(k)) throw new ApiError(422, 'post.invalid_entitlement', 'entitlement key must match ^[a-z][a-z0-9_.:-]{0,127}$');
return { visibility: vis, entitlement_key: k };
}
-- function slugFor(blog, wanted, postId = null) {
++ async function slugFor(blog, wanted, postId = null) {
let base;
try { base = slugify(wanted); } catch { throw new ApiError(422, 'post.invalid_slug', 'That title or slug has no letters or digits to make a URL from'); }
if (!SLUG_RE.test(base)) throw new ApiError(422, 'post.invalid_slug', 'slug must be lowercase letters, digits and dashes');
for (let i = 1; i < 1000; i++) {
const s = i === 1 ? base : `${base.slice(0, 75)}-${i}`;
-- const hit = q.slugTaken.get(blog.id, s);
++ const hit = await q.slugTaken.get(blog.id, s);
if (!hit || hit.id === postId) return s;
}
throw new ApiError(409, 'post.slug_taken', 'No free slug for that title');
@@@ -127,12 +127,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
return viewer.subject;
}
-- function setTerms(blog, post, input) {
++ async function setTerms(blog, post, input) {
if (input.tags !== undefined) {
const tags = listOf(input.tags);
if (tags.length > MAX_TAGS) throw new ApiError(422, 'post.too_many_tags', `At most ${MAX_TAGS} tags`);
for (const t of tags) if (t.length > 50) throw new ApiError(422, 'post.invalid_tag', 'A tag is at most 50 characters');
-- store.taxonomy.setTerms(post.id, 'tag', tags);
++ await store.taxonomy.setTerms(post.id, 'tag', tags);
}
if (input.categories !== undefined) {
const vocab = blogs.categoryVocabulary(blog);
@@@ -141,20 +141,20 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
for (const pathText of listOf(input.categories).slice(0, 10)) {
let parentId = null;
for (const name of pathText.split('>').map((s) => s.trim()).filter(Boolean).slice(0, 4)) {
-- const term = store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });
++ const term = await store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });
parentId = term.id;
}
if (parentId != null) termIds.push(parentId);
}
-- store.taxonomy.setTerms(post.id, vocab, termIds);
++ await store.taxonomy.setTerms(post.id, vocab, termIds);
}
}
-- function seriesOf(blog, input, current) {
++ async function seriesOf(blog, input, current) {
if (input.series === undefined && input.seriesId === undefined && input.series_id === undefined) return current;
const v = input.seriesId ?? input.series_id ?? input.series;
if (v == null || v === '') return { series_id: null, series_position: null };
-- let series = typeof v === 'string' && v.startsWith('ser_') ? blogs.seriesById(v) : blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);
++ let series = typeof v === 'string' && v.startsWith('ser_') ? await blogs.seriesById(v) : await blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);
if (!series || series.blog_id !== blog.id) throw new ApiError(422, 'post.invalid_series', 'That series is not on this blog');
const posRaw = input.seriesPosition ?? input.series_position;
const pos = posRaw == null || posRaw === '' ? null : parseInt(posRaw, 10);
@@@ -162,9 +162,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
return { series_id: series.id, series_position: pos };
}
-- function attachCitations(post, revision, list) {
++ async function attachCitations(post, revision, list) {
if (!Array.isArray(list) || !list.length) return;
-- store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({
++ await store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({
url: c.url || null, sourceItemId: c.sourceItemId || c.source_item_id || null, title: c.title || null,
retrievedAt: c.retrievedAt || c.retrieved_at || null,
quote: c.quote ? (typeof c.quote === 'string' ? { text: c.quote } : c.quote) : null,
@@@ -177,14 +177,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
const api = {
VISIBILITIES, MEDIA_ROLES,
-- get: (id) => q.byId.get(String(id || '')) || null,
-- bySlug: (blog, slug) => q.bySlug.get(blog.id, String(slug || '')) || null,
-- deletedBySlug: (blog, slug) => q.deletedBySlug.get(blog.id, String(slug || '')) || null,
++ get: async (id) => await q.byId.get(String(id || '')) || null,
++ bySlug: async (blog, slug) => await q.bySlug.get(blog.id, String(slug || '')) || null,
++ deletedBySlug: async (blog, slug) => await q.deletedBySlug.get(blog.id, String(slug || '')) || null,
blogOf,
/** Must exist and not be deleted, else 404. */
-- mustGet(id) {
-- const p = api.get(id);
++ async mustGet(id) {
++ const p = await api.get(id);
if (!p || p.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
return p;
},
@@@ -194,7 +194,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
* member of the blog or staff). Unlisted posts are never listed.
* filters: blogId, termIds (any), seriesId, authorSubject, restricted, limit, offset
*/
-- listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {
++ async listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {
const where = ["p.state = 'published'"];
const args = [];
if (restricted) where.push("p.visibility IN ('public','members','private')");
@@@ -209,63 +209,63 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
}
where.push("EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')");
const w = where.join(' AND ');
-- const total = db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args).n;
++ const total = (await db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args)).n;
const orderBy = order === 'series' ? 'COALESCE(p.series_position, 1e9), p.first_published_at, p.id' : 'p.published_at DESC, p.id DESC';
-- const posts = db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);
++ const posts = await db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);
return { total, posts };
},
/** Every non-deleted post of a blog, for its members' dashboard (authors: their own). */
-- listForDashboard(blog, viewer) {
-- const role = access.effectiveRole(store, viewer, blog);
++ async listForDashboard(blog, viewer) {
++ const role = await access.effectiveRole(store, viewer, blog);
if (!role && !access.isStaff(viewer)) return [];
const own = role === 'author' ? ' AND author_subject = ?' : '';
const args = role === 'author' ? [blog.id, viewer.subject] : [blog.id];
-- return db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);
++ return await db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);
},
-- head: (post) => store.revisions.head(post.id),
-- revision: (post, n) => store.revisions.get(post.id, n),
-- revisions: (post, opts) => store.revisions.list(post.id, opts),
-- diff: (post, from, to, mode) => store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),
-- pendingJobs: (post) => store.scheduler.jobs(post.id).filter((j) => j.status === 'pending' || j.status === 'running'),
-- attachments: (post) => store.attachments.list(post.id),
-- citations: (post, rev) => (rev ? store.citations.forRevision(post.id, rev) : []),
++ head: async (post) => await store.revisions.head(post.id),
++ revision: async (post, n) => await store.revisions.get(post.id, n),
++ revisions: async (post, opts) => await store.revisions.list(post.id, opts),
++ diff: async (post, from, to, mode) => await store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),
++ pendingJobs: async (post) => (await store.scheduler.jobs(post.id)).filter((j) => j.status === 'pending' || j.status === 'running'),
++ attachments: async (post) => await store.attachments.list(post.id),
++ citations: async (post, rev) => (rev ? await store.citations.forRevision(post.id, rev) : []),
// ── Writes ──────────────────────────────────────────
/** A new draft (revision 1). Returns { post, revision }. */
-- create(viewer, blog, input = {}, { traceparent } = {}) {
++ async create(viewer, blog, input = {}, { traceparent } = {}) {
const subject = actingSubject(viewer);
-- if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
++ if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
const t = title(input.title);
const content = body(input.body);
const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);
const rec = authorshipFor(viewer, input, null);
-- return store.tx(() => {
++ return await store.tx(async () => {
const now = store.now();
const id = newPostId(now);
-- const slug = slugFor(blog, input.slug || t);
-- store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path
-- const ser = seriesOf(blog, input, { series_id: null, series_position: null });
-- q.insert.run({
++ const slug = await slugFor(blog, input.slug || t);
++ await store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path
++ const ser = await seriesOf(blog, input, { series_id: null, series_position: null });
++ await q.insert.run({
id, blog_id: blog.id, slug, ...vis, author_subject: subject, ...ser,
allow_comments: input.allowComments === undefined && input.allow_comments === undefined ? 1 : (truthy(input.allowComments ?? input.allow_comments) ? 1 : 0),
noindex: truthy(input.noindex) ? 1 : 0, now,
});
-- const { revision } = store.revisions.create({
++ const { revision } = await store.revisions.create({
entityId: id, expectedRevision: 0, content, fields: { title: t, summary: summary(input.summary) },
meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',
});
-- attachCitations({ id }, revision.number, input.citations);
-- const post = q.byId.get(id);
-- setTerms(blog, post, input);
-- outbox.emit({
++ await attachCitations({ id }, revision.number, input.citations);
++ const post = await q.byId.get(id);
++ await setTerms(blog, post, input);
++ await outbox.emit({
event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'post', id, revision: revision.number },
payload: { blog: { id: blog.id, handle: blog.handle }, state: 'draft', visibility: post.visibility, authorship: rec.mode, ...(rec.workflow ? { workflow: rec.workflow } : {}) },
}, { traceparent });
-- return { post: q.byId.get(id), revision };
++ return { post: await q.byId.get(id), revision };
});
},
@@@ -274,14 +274,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
* on conflict); slug, visibility, taxonomy, series and flags change the post row. A slug
* change leaves a 301 from the old path.
*/
-- update(viewer, post, input = {}, { traceparent } = {}) {
++ async update(viewer, post, input = {}, { traceparent } = {}) {
actingSubject(viewer);
-- const blog = blogOf(post);
-- if (!access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
++ const blog = await blogOf(post);
++ if (!await access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
if (post.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
-- return store.tx(() => {
++ return await store.tx(async () => {
const before = publication.snapshot(blog, post);
-- const head = store.revisions.head(post.id);
++ const head = await store.revisions.head(post.id);
let revision = head;
let created = false;
const contentChange = input.title !== undefined || input.body !== undefined || input.summary !== undefined;
@@@ -293,7 +293,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
summary: input.summary !== undefined ? summary(input.summary) : (head.fields.summary || null),
};
const rec = authorshipFor(viewer, input, publication.authorshipOf(head));
-- const out = store.revisions.create({
++ const out = await store.revisions.create({
entityId: post.id, expectedRevision: parseInt(expected, 10), content: input.body !== undefined ? body(input.body) : head.content,
fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,
});
@@@ -302,88 +302,88 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
if (created) {
// An edit that does not send citations keeps the ones it had (the /write editor does not
// resend them); sending a list, even an empty one, replaces them.
-- if (input.citations !== undefined) attachCitations(post, revision.number, input.citations);
-- else store.citations.carryForward({ entityId: post.id, fromRevision: head.number, toRevision: revision.number, attachedBy: viewer.subject });
++ if (input.citations !== undefined) await attachCitations(post, revision.number, input.citations);
++ else await store.citations.carryForward({ entityId: post.id, fromRevision: head.number, toRevision: revision.number, attachedBy: viewer.subject });
}
}
const sets = {};
if (input.slug !== undefined && input.slug !== '' && input.slug !== post.slug) {
-- const slug = slugFor(blog, input.slug, post.id);
++ const slug = await slugFor(blog, input.slug, post.id);
if (slug !== post.slug) {
-- store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });
++ await store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });
sets.slug = slug;
}
}
if (input.visibility !== undefined || input.entitlementKey !== undefined || input.entitlement_key !== undefined) {
Object.assign(sets, visibilityOf(blog, input.visibility ?? post.visibility, input.entitlementKey ?? input.entitlement_key ?? post.entitlement_key));
}
-- Object.assign(sets, seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));
++ Object.assign(sets, await seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));
if (input.allowComments !== undefined || input.allow_comments !== undefined) sets.allow_comments = truthy(input.allowComments ?? input.allow_comments) ? 1 : 0;
if (input.noindex !== undefined) sets.noindex = truthy(input.noindex) ? 1 : 0;
const keys = Object.keys(sets);
-- db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)
++ await db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)
.run({ ...sets, now: store.now(), id: post.id });
-- setTerms(blog, post, input);
-- const { post: after } = publication.afterChange(before, post.id, { actor: viewer, traceparent });
++ await setTerms(blog, post, input);
++ const { post: after } = await publication.afterChange(before, post.id, { actor: viewer, traceparent });
return { post: after, revision, created };
});
},
/** The idempotent effect: revision N is the published one. Returns { changed, post }. */
-- applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {
-- return store.tx(() => {
-- const post = q.byId.get(postId);
++ async applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {
++ return await store.tx(async () => {
++ const post = await q.byId.get(postId);
if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
-- const rev = revisionNumber ? store.revisions.get(post.id, revisionNumber) : store.revisions.head(post.id);
++ const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));
++ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);
-- const before = publication.snapshot(blogOf(post), post);
++ const before = publication.snapshot(await blogOf(post), post);
const now = store.now();
-- db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
++ await db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);
-- const out = publication.afterChange(before, post.id, { actor, traceparent });
++ const out = await publication.afterChange(before, post.id, { actor, traceparent });
return { changed: true, post: out.post };
});
},
-- applyUnpublish(postId, actor, { traceparent } = {}) {
-- return store.tx(() => {
-- const post = q.byId.get(postId);
++ async applyUnpublish(postId, actor, { traceparent } = {}) {
++ return await store.tx(async () => {
++ const post = await q.byId.get(postId);
if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
if (post.state !== 'published') return { changed: false, post };
-- const before = publication.snapshot(blogOf(post), post);
-- db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);
-- return { changed: true, post: publication.afterChange(before, post.id, { actor, traceparent }).post };
++ const before = publication.snapshot(await blogOf(post), post);
++ await db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);
++ return { changed: true, post: (await publication.afterChange(before, post.id, { actor, traceparent })).post };
});
},
-- publish(viewer, post, { revision } = {}, ctx = {}) {
++ async publish(viewer, post, { revision } = {}, ctx = {}) {
actingSubject(viewer);
-- if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');
++ if (!await access.canWrite(store, viewer, await blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');
const n = revision == null || revision === '' ? null : parseInt(revision, 10);
-- return store.tx(() => {
-- store.scheduler.cancelPending(post.id, 'publish');
-- return api.applyPublish(post.id, n, viewer, ctx);
++ return await store.tx(async () => {
++ await store.scheduler.cancelPending(post.id, 'publish');
++ return await api.applyPublish(post.id, n, viewer, ctx);
});
},
-- unpublish(viewer, post, ctx = {}) {
++ async unpublish(viewer, post, ctx = {}) {
actingSubject(viewer);
-- if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');
-- const moderation = byStaff(viewer, blogOf(post), 'unpublish', post);
-- return store.tx(() => {
-- store.scheduler.cancelPending(post.id);
-- const cur = q.byId.get(post.id);
++ if (!await access.canWrite(store, viewer, await blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');
++ const moderation = await byStaff(viewer, await blogOf(post), 'unpublish', post);
++ return await store.tx(async () => {
++ await store.scheduler.cancelPending(post.id);
++ const cur = await q.byId.get(post.id);
let out;
if (cur.state === 'scheduled') {
-- db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
-- out = { changed: true, post: q.byId.get(post.id) };
++ await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
++ out = { changed: true, post: await q.byId.get(post.id) };
} else {
-- out = api.applyUnpublish(post.id, viewer, ctx);
++ out = await api.applyUnpublish(post.id, viewer, ctx);
}
-- if (moderation && out.changed) moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });
++ if (moderation && out.changed) await moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });
return out;
});
},
@@@ -392,38 +392,38 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
* Schedule a publish (of revision N, default the head) or an unpublish at a future time.
* Idempotent: the same (post, action, time, revision) is one job.
*/
-- schedule(viewer, post, { at, revision, action = 'publish' } = {}) {
++ async schedule(viewer, post, { at, revision, action = 'publish' } = {}) {
actingSubject(viewer);
-- const blog = blogOf(post);
-- if (!access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');
++ const blog = await blogOf(post);
++ if (!await access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');
if (!['publish', 'unpublish'].includes(action)) throw new ApiError(422, 'schedule.invalid_action', 'action is publish or unpublish');
const t = at instanceof Date ? at.getTime() : typeof at === 'number' ? at : Date.parse(at);
if (!Number.isFinite(t)) throw new ApiError(422, 'schedule.invalid_time', 'at must be an ISO 8601 time');
if (t <= store.now()) throw new ApiError(422, 'schedule.in_past', 'Scheduled time must be in the future');
-- const rev = action === 'publish' ? (revision ? store.revisions.get(post.id, parseInt(revision, 10)) : store.revisions.head(post.id)) : null;
++ const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;
if (action === 'publish') {
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));
++ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);
}
-- return store.tx(() => {
-- const { job, created } = store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
-- const cur = q.byId.get(post.id);
++ return await store.tx(async () => {
++ const { job, created } = await store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
++ const cur = await q.byId.get(post.id);
if (action === 'publish' && (cur.state === 'draft' || cur.state === 'unpublished')) {
-- db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);
++ await db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);
}
-- return { job, created, post: q.byId.get(post.id) };
++ return { job, created, post: await q.byId.get(post.id) };
});
},
-- cancelSchedule(viewer, post) {
++ async cancelSchedule(viewer, post) {
actingSubject(viewer);
-- if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');
-- return store.tx(() => {
-- const n = store.scheduler.cancelPending(post.id);
-- const cur = q.byId.get(post.id);
-- if (cur.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
-- return { cancelled: n, post: q.byId.get(post.id) };
++ if (!await access.canWrite(store, viewer, await blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');
++ return await store.tx(async () => {
++ const n = await store.scheduler.cancelPending(post.id);
++ const cur = await q.byId.get(post.id);
++ if (cur.state === 'scheduled') await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
++ return { cancelled: n, post: await q.byId.get(post.id) };
});
},
@@@ -434,15 +434,15 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
async runScheduled(worker) {
const summary = await store.scheduler.runDue({
worker,
-- handler: (job) => (job.action === 'publish'
-- ? api.applyPublish(job.entityId, job.revision, null).changed
-- : api.applyUnpublish(job.entityId, null).changed),
++ handler: async (job) => (job.action === 'publish'
++ ? (await api.applyPublish(job.entityId, job.revision, null)).changed
++ : (await api.applyUnpublish(job.entityId, null)).changed),
});
for (const job of summary.failed) {
-- store.tx(() => {
-- const post = q.byId.get(job.entityId);
-- if (post && post.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
-- outbox.emit({
++ await store.tx(async () => {
++ const post = await q.byId.get(job.entityId);
++ if (post && post.state === 'scheduled') await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
++ await outbox.emit({
event_type: 'blog.schedule.failed', actor: { type: 'service', id: 'blog' }, visibility: 'internal', priority: 'important',
subject: { type: 'post', id: job.entityId, ...(job.revision ? { revision: job.revision } : {}) },
payload: { job_id: job.id, action: job.action, revision: job.revision, run_at: job.runAt, attempts: job.attempts, error: job.lastError, blog_id: post ? post.blog_id : null },
@@@ -454,68 +454,68 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
},
/** Soft delete: readers get 410, Search a tombstone, revisions are kept. */
-- remove(viewer, post, ctx = {}) {
++ async remove(viewer, post, ctx = {}) {
actingSubject(viewer);
-- const blog = blogOf(post);
-- if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');
-- const moderation = byStaff(viewer, blog, 'delete', post);
-- return store.tx(() => {
-- store.scheduler.cancelPending(post.id);
-- const cur = q.byId.get(post.id);
++ const blog = await blogOf(post);
++ if (!await access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');
++ const moderation = await byStaff(viewer, blog, 'delete', post);
++ return await store.tx(async () => {
++ await store.scheduler.cancelPending(post.id);
++ const cur = await q.byId.get(post.id);
const before = publication.snapshot(blog, cur);
const now = store.now();
-- db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);
-- if (moderation && cur.state !== 'deleted') moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });
-- return publication.afterChange(before, post.id, { actor: viewer, ...ctx });
++ await db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);
++ if (moderation && cur.state !== 'deleted') await moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });
++ return await publication.afterChange(before, post.id, { actor: viewer, ...ctx });
});
},
/** Revert = a new revision copying revision N (content, fields and its authorship). */
-- revert(viewer, post, { toRevision, expectedRevision } = {}) {
++ async revert(viewer, post, { toRevision, expectedRevision } = {}) {
actingSubject(viewer);
-- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
-- const target = store.revisions.get(post.id, parseInt(toRevision, 10));
++ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
++ const target = await store.revisions.get(post.id, parseInt(toRevision, 10));
if (!target) throw new ApiError(404, 'revision.not_found', `No revision ${toRevision}`);
-- return store.tx(() => {
-- const { revision } = store.revisions.revert({
++ return await store.tx(async () => {
++ const { revision } = await store.revisions.revert({
entityId: post.id, toRevision: target.number, expectedRevision: parseInt(expectedRevision, 10),
author: authorLabel(viewer), meta: { authorship: publication.authorshipOf(target) },
});
-- store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });
-- db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);
-- return { revision, post: q.byId.get(post.id) };
++ await store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });
++ await db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);
++ return { revision, post: await q.byId.get(post.id) };
});
},
/** A person's review of one revision (what lets an AI draft be published and indexed). */
-- review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
++ async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
-- const blog = blogOf(post);
-- if (!access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
++ const blog = await blogOf(post);
++ if (!await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
const n = parseInt(revision, 10);
-- if (!store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
-- return store.tx(() => {
++ if (!await store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
++ return await store.tx(async () => {
const before = publication.snapshot(blog, post);
-- const row = store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
-- publication.afterChange(before, post.id, { actor: viewer, ...ctx });
++ const row = await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
++ await publication.afterChange(before, post.id, { actor: viewer, ...ctx });
return row;
});
},
-- attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {
++ async attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {
actingSubject(viewer);
-- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
++ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
const id = String(mediaId || '').trim();
if (!isMediaId(id) || !id.startsWith('med_')) throw new ApiError(422, 'media.invalid_id', 'Attach an OpenVibe.Media object id (med_…)');
if (!MEDIA_ROLES.includes(role)) throw new ApiError(422, 'media.invalid_role', `role must be one of ${MEDIA_ROLES.join(', ')}`);
const pos = parseInt(position, 10);
-- return store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });
++ return await store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });
},
-- detach(viewer, post, attachmentId) {
++ async detach(viewer, post, attachmentId) {
actingSubject(viewer);
-- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
-- return store.attachments.detach(post.id, attachmentId);
++ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
++ return await store.attachments.detach(post.id, attachmentId);
},
};
return api;
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex fa8045e..7c89f32 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -40,12 +40,12 @@ function createPublication({ store, config, outbox }) {
return (rev && rev.meta && rev.meta.authorship) || null;
}
-- function reviewOf(post, rev) {
-- return rev ? store.reviews.latest(post.id, rev.number) : null;
++ async function reviewOf(post, rev) {
++ return rev ? await store.reviews.latest(post.id, rev.number) : null;
}
/** The gate's decision for a post at one revision (the published one by default). */
-- function decide(blog, post, rev, { state } = {}) {
++ async function decide(blog, post, rev, { state } = {}) {
const rec = authorshipOf(rev);
const facts = {
state: state || post.state,
@@@ -54,24 +54,24 @@ function createPublication({ store, config, outbox }) {
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
noindex: Boolean(post.noindex),
};
-- if (rec) Object.assign(facts, authorship.gateFacts(rec, reviewOf(post, rev)));
++ if (rec) Object.assign(facts, authorship.gateFacts(rec, await reviewOf(post, rev)));
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
}
/** The revision readers see (published) or, for a never-published post, the head. */
-- function visibleRevision(post) {
-- return post.published_revision ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);
++ async function visibleRevision(post) {
++ return post.published_revision ? await store.revisions.get(post.id, post.published_revision) : await store.revisions.head(post.id);
}
-- function aclFor(blog, post) {
-- const subjects = [...new Set([...membersOf.all(blog.id).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));
++ async function aclFor(blog, post) {
++ const subjects = [...new Set([...(await membersOf.all(blog.id)).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));
if (post.visibility === 'members') return { entitlements: [post.entitlement_key], subjects };
if (post.visibility === 'private') return { subjects };
return {};
}
-- function termsOf(post) {
-- const terms = store.taxonomy.termsFor(post.id);
++ async function termsOf(post) {
++ const terms = await store.taxonomy.termsFor(post.id);
return {
tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => t.name),
categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => t.name),
@@@ -82,16 +82,16 @@ function createPublication({ store, config, outbox }) {
* The index document as it would describe this post (real visibility, for the product events),
* or a tombstone when it is not published.
*/
-- function documentFor(blog, post, { forSearch }) {
-- const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;
++ async function documentFor(blog, post, { forSearch }) {
++ const rev = post.published_revision ? await store.revisions.get(post.id, post.published_revision) : null;
const identity = { owner: OWNER, type: 'post', id: post.id, revision: 0 };
-- if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? decide(blog, post, rev) : null };
-- const decision = decide(blog, post, rev);
++ if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? await decide(blog, post, rev) : null };
++ const decision = await decide(blog, post, rev);
if (forSearch && (post.visibility !== 'public' || !decision.listable)) return { doc: hooks.tombstone(identity), decision };
-- const { tags, categories } = termsOf(post);
-- const series = post.series_id ? db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;
++ const { tags, categories } = await termsOf(post);
++ const series = post.series_id ? await db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;
const rec = authorshipOf(rev);
-- const acl = aclFor(blog, post);
++ const acl = await aclFor(blog, post);
// Members-only: whatever carries this document (the blog.post.* events) gets the teaser — the
// title and the author's summary — never the body. (Search itself only ever gets a tombstone.)
const gated = post.visibility === 'members';
@@@ -107,7 +107,7 @@ function createPublication({ store, config, outbox }) {
body: gated ? '' : ssr.markdownToText(rev.content),
facets: { blog: blog.handle, tags, categories, ...(series ? { series: series.slug } : {}) },
authorship: rec,
-- citations: store.citations.forRevision(post.id, rev.number),
++ citations: await store.citations.forRevision(post.id, rev.number),
decision,
publishedAt: post.first_published_at,
updatedAt: rev.createdAt,
@@@ -117,13 +117,13 @@ function createPublication({ store, config, outbox }) {
}
/** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
-- function syncIndex(blog, post, { traceparent } = {}) {
-- const { doc } = documentFor(blog, post, { forSearch: true });
-- const prev = store.sequencer.current(OWNER, 'post', post.id);
++ async function syncIndex(blog, post, { traceparent } = {}) {
++ const { doc } = await documentFor(blog, post, { forSearch: true });
++ const prev = await store.sequencer.current(OWNER, 'post', post.id);
if (doc.deleted && prev == null) return null; // never indexed: nothing to remove
-- const stamped = store.sequencer.stamp(doc);
++ const stamped = await store.sequencer.stamp(db, doc); // db: joins the ambient transaction
if (prev != null && stamped.revision === prev) return null; // unchanged: nothing to send
-- return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
++ return await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
}
/** Snapshot for actionFor(before, after). */
@@@ -135,22 +135,22 @@ function createPublication({ store, config, outbox }) {
* Emit the product event for a transition (blog.post.published|updated|unpublished|deleted),
* and re-sync Search. Inside the caller's transaction, after the row changed.
*/
-- function afterChange(before, postId, { actor, traceparent } = {}) {
-- const post = db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);
-- const blog = blogById.get(post.blog_id);
++ async function afterChange(before, postId, { actor, traceparent } = {}) {
++ const post = await db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);
++ const blog = await blogById.get(post.blog_id);
const after = snapshot(blog, post);
let action = hooks.actionFor(before, after);
if (!action && before && before.state === 'published' && after.state === 'published' && before.url !== after.url) action = 'updated';
let event = null;
if (action) {
-- const { doc, decision } = documentFor(blog, post, { forSearch: false });
-- event = outbox.emit(hooks.publicationEvent({
++ const { doc, decision } = await documentFor(blog, post, { forSearch: false });
++ event = await outbox.emit(hooks.publicationEvent({
product: OWNER, type: 'post', action, id: post.id, revision: post.published_revision || 0,
actor: actorRef(actor), document: doc, decision, now: store.now(),
extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },
}), { traceparent });
}
-- syncIndex(blog, post, { traceparent });
++ await syncIndex(blog, post, { traceparent });
return { action, event, post, blog };
}
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
iindex 6f75249..07646ac 100644
---- a/server/domain/reading.js
++++ b/server/domain/reading.js
@@@ -24,8 +24,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
feed: (blog, kind) => `${blog.kind === 'official' ? '' : base(blog)}/${{ rss: 'feed.xml', atom: 'atom.xml', json: 'feed.json' }[kind]}`,
};
-- function feedsOf(blog) {
-- const s = blogs.feedSettings(blog);
++ async function feedsOf(blog) {
++ const s = await blogs.feedSettings(blog);
const out = [];
if (s.rss) out.push({ type: 'rss', href: urls.feed(blog, 'rss'), title: `${blog.title} (RSS)`, label: 'RSS', mime: 'application/rss+xml' });
if (s.atom) out.push({ type: 'atom', href: urls.feed(blog, 'atom'), title: `${blog.title} (Atom)`, label: 'Atom', mime: 'application/atom+xml' });
@@@ -33,49 +33,61 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
return out;
}
-- function termsView(post, blog, { networkTags = false } = {}) {
-- const terms = store.taxonomy.termsFor(post.id);
++ async function termsView(post, blog, { networkTags = false } = {}) {
++ const terms = await store.taxonomy.termsFor(post.id);
return {
tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => ({ ...t, url: urls.tag(t, networkTags ? null : blog) })),
categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => ({ ...t, url: urls.category(blog, t) })),
};
}
-- /** List items for rows of published posts (possibly from several blogs). */
++ /** Blogs of these rows, one lookup per distinct blog. */
++ async function blogsFor(rows) {
++ const out = new Map();
++ for (const id of new Set(rows.map((p) => p.blog_id))) out.set(id, await blogs.get(id));
++ return out;
++ }
++ const revRefs = (rows) => rows.map((p) => ({ entityId: p.id, revision: p.published_revision }));
++
++ /** List items for rows of published posts (possibly from several blogs): revisions in one query (no N+1). */
async function listItems(rows, { perBlogLinks = true } = {}) {
-- const who = await people.many(rows.map((p) => p.author_subject));
-- return rows.map((post) => {
-- const blog = blogs.get(post.blog_id);
-- const rev = store.revisions.get(post.id, post.published_revision);
++ const [who, revs, blogById] = await Promise.all([people.many(rows.map((p) => p.author_subject)), store.revisions.getMany(revRefs(rows)), blogsFor(rows)]);
++ const out = [];
++ for (const [i, post] of rows.entries()) {
++ const rev = revs[i];
++ if (!rev) continue;
++ const blog = blogById.get(post.blog_id);
const person = who.get(post.author_subject);
-- const { tags } = termsView(post, blog, { networkTags: !perBlogLinks });
-- return {
++ const { tags } = await termsView(post, blog, { networkTags: !perBlogLinks });
++ out.push({
post, rev, blog,
url: publication.postPath(blog, post),
author: person ? { name: person.name, url: urls.author(person, blog) } : null,
tags,
badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,
-- };
-- }).filter((it) => it.rev);
++ });
++ }
++ return out;
}
-- function categoriesTree(blog) {
-- return store.taxonomy.tree(blogs.categoryVocabulary(blog));
++ async function categoriesTree(blog) {
++ return await store.taxonomy.tree(blogs.categoryVocabulary(blog));
}
/** Feed items (openvibe-publishing/seo feed builders skip anything the gate does not list). */
async function feedItems(blog, { limit, fullContent }) {
-- const { posts: rows } = postsApi.listPublished({ blogId: blog.id, limit, restricted: false });
-- const who = await people.many(rows.map((p) => p.author_subject));
-- return rows.map((post) => {
-- const rev = store.revisions.get(post.id, post.published_revision);
-- if (!rev) return null;
-- const decision = publication.decide(blog, post, rev);
++ const { posts: rows } = await postsApi.listPublished({ blogId: blog.id, limit, restricted: false });
++ const [who, revs] = await Promise.all([people.many(rows.map((p) => p.author_subject)), store.revisions.getMany(revRefs(rows))]);
++ const out = [];
++ for (const [i, post] of rows.entries()) {
++ const rev = revs[i];
++ if (!rev) continue;
++ const decision = await publication.decide(blog, post, rev);
const person = who.get(post.author_subject);
-- const atts = store.attachments.list(post.id);
++ const atts = await store.attachments.list(post.id);
const cover = atts.find((a) => a.role === 'cover' && !a.broken);
const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';
-- return {
++ out.push({
id: publication.feedId(post),
url: publication.postUrl(blog, post),
title: rev.fields.title,
@@@ -85,18 +97,19 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
updated: rev.createdAt,
decision,
authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],
-- tags: termsView(post, blog).tags.map((t) => t.name),
++ tags: (await termsView(post, blog)).tags.map((t) => t.name),
image: cover ? media.urlFor(cover.mediaId) : null,
-- };
-- }).filter(Boolean);
++ });
++ }
++ return out;
}
/** The machine-readable representation of a post (same content as the page). */
-- function postJson({ blog, post, rev, person, decision }) {
++ async function postJson({ blog, post, rev, person, decision }) {
const rec = publication.authorshipOf(rev);
-- const review = publication.reviewOf(post, rev);
-- const { tags, categories } = termsView(post, blog);
-- const series = blogs.seriesById(post.series_id);
++ const review = await publication.reviewOf(post, rev);
++ const { tags, categories } = await termsView(post, blog);
++ const series = await blogs.seriesById(post.series_id);
return {
id: post.id,
url: publication.postUrl(blog, post),
@@@ -114,8 +127,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
tags: tags.map((t) => t.name),
categories: categories.map((c) => c.name),
series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,
-- media: store.attachments.list(post.id).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),
-- citations: store.citations.forRevision(post.id, rev.number).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),
++ media: (await store.attachments.list(post.id)).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),
++ citations: (await store.citations.forRevision(post.id, rev.number)).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),
indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.codes },
};
}
@@@ -147,7 +160,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
* OpenVibe.VIP. The official blog has no VIP owner: no join link.
*/
async function teaser(blog, post, { reason = null } = {}) {
-- const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;
++ const rev = post.published_revision ? await store.revisions.get(post.id, post.published_revision) : null;
const owner = blog.owner_subject ? await people.one(blog.owner_subject) : null;
return {
id: post.id,
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex c9b9762..92a98d8 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -61,9 +61,9 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
* blog.moderation.action, inside the caller's transaction. actorSubject: the staff member;
* target: { type, id, owner_subject? }. Never the content.
*/
-- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
++ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
-- return emit({
++ return await emit({
event_type: 'blog.moderation.action',
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'blog' },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex e3d837c..e7fb0eb 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -29,7 +29,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages and feeds
* people read.
*/
--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
@@@ -86,7 +86,7 @@ const BUDGETS = {
* limits(name, own) middleware for one app, plus limits.reads(name, { skip }) (the defaults on every
* GET/HEAD, a first-party service reading for itself not counted) and limits.budget(name).
*/
--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {
const refused = registry
? registry.counter({ name: 'blog_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
@@@ -94,6 +94,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
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/http/api.js b/server/http/api.js
iindex 5d729dc..4af5167 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -66,30 +66,30 @@ function createApi(ctx) {
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
-- function mustBlog(req) {
-- const blog = blogs.byHandle(req.params.handle);
++ async function mustBlog(req) {
++ const blog = await blogs.byHandle(req.params.handle);
if (!blog || (blog.status !== 'active' && !access.isStaff(req.viewer))) throw new ApiError(404, 'blog.not_found', 'No such blog');
return blog;
}
-- const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
++ const isMember = async (blog, viewer) => Boolean(await access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
-- function blogDto(blog, { full = false } = {}) {
++ async function blogDto(blog, { full = false } = {}) {
const out = {
id: blog.id, kind: blog.kind, handle: blog.handle, title: blog.title, description: blog.description,
url: publication.abs(publication.blogPath(blog)), theme: blog.theme, language: blog.language,
-- feeds: reading.feedsOf(blog).map((f) => ({ type: f.type, url: publication.abs(f.href) })),
++ feeds: (await reading.feedsOf(blog)).map((f) => ({ type: f.type, url: publication.abs(f.href) })),
};
-- if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: blogs.feedSettings(blog) });
++ if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: await blogs.feedSettings(blog) });
return out;
}
-- function postDto(post, { full = false } = {}) {
-- const blog = posts.blogOf(post);
-- const rev = full ? posts.head(post) : posts.revision(post, post.published_revision);
-- const shown = post.published_revision ? posts.revision(post, post.published_revision) : rev;
-- const decision = shown ? publication.decide(blog, post, shown) : null;
-- const { tags, categories } = publication.termsOf(post);
-- const series = blogs.seriesById(post.series_id);
++ async function postDto(post, { full = false } = {}) {
++ const blog = await posts.blogOf(post);
++ const rev = full ? await posts.head(post) : await posts.revision(post, post.published_revision);
++ const shown = post.published_revision ? await posts.revision(post, post.published_revision) : rev;
++ const decision = shown ? await publication.decide(blog, post, shown) : null;
++ const { tags, categories } = await publication.termsOf(post);
++ const series = await blogs.seriesById(post.series_id);
const out = {
id: post.id, blog: { id: blog.id, handle: blog.handle }, slug: post.slug, url: publication.postUrl(blog, post),
state: post.state, visibility: post.visibility, author_subject: post.author_subject,
@@@ -105,127 +105,127 @@ function createApi(ctx) {
const rec = publication.authorshipOf(rev);
Object.assign(out, {
entitlement_key: post.entitlement_key, body: rev ? rev.content : null,
-- authorship: rec, review: rev ? publication.reviewOf(post, rev) : null,
-- scheduled: posts.pendingJobs(post), attachments: posts.attachments(post),
-- citations: rev ? posts.citations(post, rev.number) : [],
++ authorship: rec, review: rev ? await publication.reviewOf(post, rev) : null,
++ scheduled: await posts.pendingJobs(post), attachments: await posts.attachments(post),
++ citations: rev ? await posts.citations(post, rev.number) : [],
});
}
return out;
}
-- function mustPost(req, cap) {
-- const post = posts.mustGet(req.params.id);
-- return { post, blog: posts.blogOf(post), cap };
++ async function mustPost(req, cap) {
++ const post = await posts.mustGet(req.params.id);
++ return { post, blog: await posts.blogOf(post), cap };
}
-- function after(before, postId, req) {
-- effects.after(before, posts.get(postId), req.ov);
++ async function after(before, postId, req) {
++ await effects.after(before, await posts.get(postId), req.ov);
}
// ── Blogs ───────────────────────────────────────────────
-- router.get('/blogs/:handle', run((req) => {
-- const blog = mustBlog(req);
-- return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };
++ router.get('/blogs/:handle', run(async (req) => {
++ const blog = await mustBlog(req);
++ return { blog: await blogDto(blog, { full: await isMember(blog, req.viewer) }) };
}));
-- router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run((req) => {
++ router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run(async (req) => {
const b = req.body || {};
const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;
-- const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });
-- return { blog: blogDto(blog, { full: true }), created };
++ const { blog, created } = await blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });
++ return { blog: await blogDto(blog, { full: true }), created };
}, (out) => (out.created ? 201 : 200)));
-- router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run((req) => {
-- const blog = mustBlog(req);
-- if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');
++ router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run(async (req) => {
++ const blog = await mustBlog(req);
++ if (!await access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');
const b = req.body || {};
-- const updated = store.tx(() => {
-- const u = blogs.update(blog, { title: b.title, description: b.description, language: b.language });
-- if (b.feeds) blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });
-- return blogs.get(u.id);
++ const updated = await store.tx(async () => {
++ const u = await blogs.update(blog, { title: b.title, description: b.description, language: b.language });
++ if (b.feeds) await blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });
++ return await blogs.get(u.id);
});
-- return { blog: blogDto(updated, { full: true }) };
++ return { blog: await blogDto(updated, { full: true }) };
}));
-- router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run((req) => {
-- const blog = mustBlog(req);
-- if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');
-- return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };
++ router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run(async (req) => {
++ const blog = await mustBlog(req);
++ if (!await access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');
++ return { blog: await blogDto(await blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };
}));
router.get('/blogs/:handle/members', guard('blog.member.manage'), run(async (req) => {
-- const blog = mustBlog(req);
-- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');
-- const list = blogs.members(blog);
++ const blog = await mustBlog(req);
++ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');
++ const list = await blogs.members(blog);
const who = await people.many(list.map((m) => m.subject));
return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };
}));
-- router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run((req) => {
-- const blog = mustBlog(req);
-- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
-- return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };
++ router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run(async (req) => {
++ const blog = await mustBlog(req);
++ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
++ return { member: await blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };
}));
-- router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run((req) => {
-- const blog = mustBlog(req);
-- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
-- return { removed: blogs.removeMember(blog, req.params.subject) };
++ router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run(async (req) => {
++ const blog = await mustBlog(req);
++ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
++ return { removed: await blogs.removeMember(blog, req.params.subject) };
}));
-- router.get('/blogs/:handle/posts', run((req) => {
-- const blog = mustBlog(req);
++ router.get('/blogs/:handle/posts', run(async (req) => {
++ const blog = await mustBlog(req);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
if (req.query.all === '1') {
if (req.viewer.kind === 'service' && !checkCapability(req.viewer.claims, 'blog.post.read').allowed) throw new ApiError(403, 'capability.denied', 'blog.post.read not granted');
-- if (!isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');
-- return { posts: posts.listForDashboard(blog, req.viewer).slice(offset, offset + limit).map((p) => postDto(p, { full: false })) };
++ if (!await isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');
++ return { posts: await Promise.all((await posts.listForDashboard(blog, req.viewer)).slice(offset, offset + limit).map((p) => postDto(p, { full: false }))) };
}
-- const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted: isMember(blog, req.viewer), limit, offset });
-- return { total, posts: rows.map((p) => postDto(p)) };
++ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted: await isMember(blog, req.viewer), limit, offset });
++ return { total, posts: await Promise.all(rows.map((p) => postDto(p))) };
}));
// The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and
// the latest patch notes post. Public: every site's "recently shipped" reads it.
-- router.get('/changelog', run((req, res) => {
++ router.get('/changelog', run(async (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=60');
const service = /^[a-z][a-z0-9-]{1,39}$/.test(String(req.query.service || '')) ? String(req.query.service) : null;
-- const postOf = (row) => {
-- const p = row && posts.get(row.post_id);
-- const blog = p ? blogs.get(p.blog_id) : null;
++ const postOf = async (row) => {
++ const p = row && await posts.get(row.post_id);
++ const blog = p ? await blogs.get(p.blog_id) : null;
if (!p || !blog || p.state !== 'published') return null;
-- return { id: p.id, title: (posts.head(p) || { fields: {} }).fields.title || null, url: publication.abs(publication.postPath(blog, p)), published_at: p.published_at || null, entries: row.entries };
++ return { id: p.id, title: (await posts.head(p) || { fields: {} }).fields.title || null, url: publication.abs(publication.postPath(blog, p)), published_at: p.published_at || null, entries: row.entries };
};
-- const page = ctx.changelog.page({ service, limit: req.query.limit, before: req.query.before });
-- const recent = ctx.changelog.recentPosts(5).map(postOf).filter(Boolean);
++ const page = await ctx.changelog.page({ service, limit: req.query.limit, before: req.query.before });
++ const recent = (await Promise.all((await ctx.changelog.recentPosts(5)).map(postOf))).filter(Boolean);
return {
service, entries: page.entries, next: page.next, latest_post: recent[0] || null, posts: recent,
-- ...(service ? {} : { sites: ctx.changelog.sites() }),
++ ...(service ? {} : { sites: await ctx.changelog.sites() }),
};
}));
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {
-- const blog = mustBlog(req);
++ const blog = await mustBlog(req);
if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));
-- after(null, post.id, req);
-- return { post: postDto(post, { full: true }), revision: revision.number };
++ await after(null, post.id, req);
++ return { post: await postDto(post, { full: true }), revision: revision.number };
}, 201));
-- router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run((req) => {
-- const blog = mustBlog(req);
-- const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
-- after(null, post.id, req);
-- return { post: postDto(post, { full: true }), revision: revision.number };
++ router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run(async (req) => {
++ const blog = await mustBlog(req);
++ const { post, revision } = await posts.create(req.viewer, blog, req.body || {}, tp(req));
++ await after(null, post.id, req);
++ return { post: await postDto(post, { full: true }), revision: revision.number };
}, 201));
router.get('/blogs/:handle/feed', guard('blog.feed.read'), run(async (req, res) => {
-- const blog = mustBlog(req);
-- const s = blogs.feedSettings(blog);
++ const blog = await mustBlog(req);
++ const s = await blogs.feedSettings(blog);
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
res.set('Cache-Control', 'public, max-age=300');
res.removeHeader('X-Robots-Tag');
@@@ -235,82 +235,82 @@ function createApi(ctx) {
// ── Posts ───────────────────────────────────────────────
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
-- const { post, blog } = mustPost(req);
-- if (access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: postDto(post, { full: true }) };
++ const { post, blog } = await mustPost(req);
++ if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: await postDto(post, { full: true }) };
const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);
// Members only: the teaser (title, the author's summary) and the join link, never the body.
if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });
if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');
-- return { post: postDto(post) };
++ return { post: await postDto(post) };
}));
const write = (method, path, capGuard, limit, fn, status = 200) => router[method](path, capGuard, limit, jsonBody, run(async (req) => {
-- const { post } = mustPost(req);
++ const { post } = await mustPost(req);
const before = { ...post };
const out = await fn(req, post, req.body || {});
-- after(before, post.id, req);
++ await after(before, post.id, req);
return out;
}, status));
-- write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {
++ write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), async (req, post, b) => {
const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };
-- const out = posts.update(req.viewer, post, input, tp(req));
-- return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };
++ const out = await posts.update(req.viewer, post, input, tp(req));
++ return { post: await postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };
});
-- write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), (req, post, b) => {
-- const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));
-- return { post: postDto(out.post, { full: true }), changed: out.changed };
++ write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), async (req, post, b) => {
++ const out = await posts.publish(req.viewer, post, { revision: b.revision }, tp(req));
++ return { post: await postDto(out.post, { full: true }), changed: out.changed };
});
-- write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post, b) => {
-- const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });
-- return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };
++ write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), async (req, post, b) => {
++ const out = await posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });
++ return { job: out.job, created: out.created, post: await postDto(out.post, { full: true }) };
}, 201);
-- write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post) => {
-- const out = posts.cancelSchedule(req.viewer, post);
-- return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };
++ write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), async (req, post) => {
++ const out = await posts.cancelSchedule(req.viewer, post);
++ return { cancelled: out.cancelled, post: await postDto(out.post, { full: true }) };
});
-- write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), (req, post) => {
-- const out = posts.unpublish(req.viewer, post, tp(req));
-- return { post: postDto(out.post, { full: true }), changed: out.changed };
++ write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), async (req, post) => {
++ const out = await posts.unpublish(req.viewer, post, tp(req));
++ return { post: await postDto(out.post, { full: true }), changed: out.changed };
});
-- write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), (req, post) => {
-- posts.remove(req.viewer, post, tp(req));
++ write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), async (req, post) => {
++ await posts.remove(req.viewer, post, tp(req));
return { deleted: true, id: post.id };
});
-- write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {
-- const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
-- return { revision: out.revision.number, post: postDto(out.post, { full: true }) };
++ write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), async (req, post, b) => {
++ const out = await posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
++ return { revision: out.revision.number, post: await postDto(out.post, { full: true }) };
}, 201);
router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {
-- const { post } = mustPost(req);
++ const { post } = await mustPost(req);
const b = req.body || {};
-- const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
-- after({ ...post }, post.id, req);
++ const review = await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
++ await after({ ...post }, post.id, req);
return { review };
}, 201));
-- write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), (req, post, b) => ({
-- attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),
++ write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), async (req, post, b) => ({
++ attachment: await posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),
}), 201);
-- write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));
++ write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), async (req, post) => ({ removed: await posts.detach(req.viewer, post, req.params.aid) }));
-- function readable(req) {
-- const { post, blog } = mustPost(req);
-- if (!(access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');
++ async function readable(req) {
++ const { post, blog } = await mustPost(req);
++ if (!(await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');
return post;
}
-- router.get('/posts/:id/revisions', guard('blog.post.read'), run((req) => {
-- const post = readable(req);
-- return { revisions: posts.revisions(post, { limit: req.query.limit, before: req.query.before }).map(({ content, ...r }) => ({ ...r, length: content.length })) };
++ router.get('/posts/:id/revisions', guard('blog.post.read'), run(async (req) => {
++ const post = await readable(req);
++ return { revisions: (await posts.revisions(post, { limit: req.query.limit, before: req.query.before })).map(({ content, ...r }) => ({ ...r, length: content.length })) };
}));
-- router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run((req) => {
-- const post = readable(req);
-- const rev = posts.revision(post, parseInt(req.params.n, 10));
++ router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run(async (req) => {
++ const post = await readable(req);
++ const rev = await posts.revision(post, parseInt(req.params.n, 10));
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
-- return { revision: rev, citations: posts.citations(post, rev.number) };
++ return { revision: rev, citations: await posts.citations(post, rev.number) };
}));
-- router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run((req) => {
-- const post = readable(req);
-- return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
++ router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run(async (req) => {
++ const post = await readable(req);
++ return { diff: await posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
}));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 79e1090..e233cf0 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -22,16 +22,22 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
const { db } = store;
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
-- const publicPosts = () => db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
++ const publicPosts = async () => await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
WHERE p.state = 'published' AND p.visibility = 'public' AND b.status = 'active'
ORDER BY p.published_at DESC LIMIT 50000`).all();
-- function postEntries() {
-- return publicPosts().map((post) => {
-- const blog = blogs.get(post.blog_id);
-- const rev = store.revisions.get(post.id, post.published_revision);
-- return { post, blog, rev, decision: publication.decide(blog, post, rev) };
-- });
++ async function postEntries() {
++ // Revisions in one query and each blog once, whatever the number of posts (no N+1).
++ const rows = await publicPosts();
++ const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));
++ const blogById = new Map();
++ for (const id of new Set(rows.map((p) => p.blog_id))) blogById.set(id, await blogs.get(id));
++ const out = [];
++ for (const [i, post] of rows.entries()) {
++ const blog = blogById.get(post.blog_id);
++ out.push({ post, blog, rev: revs[i], decision: await publication.decide(blog, post, revs[i]) });
++ }
++ return out;
}
const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
@@@ -46,8 +52,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
});
-- router.get('/llms.txt', (_req, res) => {
-- const official = blogs.official();
++ router.get('/llms.txt', async (_req, res) => {
++ const official = await blogs.official();
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Blog',
summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
@@@ -66,8 +72,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
}));
});
-- router.get('/sitemap.xml', (_req, res) => {
-- const entries = postEntries().filter((e) => e.decision.indexable);
++ router.get('/sitemap.xml', async (_req, res) => {
++ const entries = (await postEntries()).filter((e) => e.decision.indexable);
const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;
xml(res, seo.sitemapIndex([
{ loc: abs('/sitemaps/posts.xml'), ...(newest ? { lastmod: newest } : {}) },
@@@ -75,15 +81,15 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
]));
});
-- router.get('/sitemaps/posts.xml', (_req, res) => {
-- const out = seo.sitemap(postEntries().map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));
++ router.get('/sitemaps/posts.xml', async (_req, res) => {
++ const out = seo.sitemap((await postEntries()).map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));
xml(res, out.files[0]);
});
-- router.get('/sitemaps/blogs.xml', (_req, res) => {
++ router.get('/sitemaps/blogs.xml', async (_req, res) => {
const byBlog = new Map();
const bySeries = new Map();
-- for (const e of postEntries()) {
++ for (const e of await postEntries()) {
if (!e.decision.indexable) continue;
const t = e.rev.createdAt;
if (!byBlog.has(e.blog.id) || byBlog.get(e.blog.id).lastmod < t) byBlog.set(e.blog.id, { blog: e.blog, lastmod: t });
@@@ -96,7 +102,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
entries.push({ loc: abs(path), lastmod, decision: listing(path) });
}
for (const [id, { blog, lastmod }] of bySeries) {
-- const s = blogs.seriesById(id);
++ const s = await blogs.seriesById(id);
if (!s) continue;
const path = reading.urls.series(blog, s);
entries.push({ loc: abs(path), lastmod, decision: listing(path) });
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 8205e28..d421a30 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -85,17 +85,17 @@ function createEditorRoutes(ctx) {
const csrf = (req) => csrfToken(config, req.viewer);
const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 200) } : null);
-- function blogFor(req) {
-- const blog = blogs.byHandle(req.params.handle);
++ async function blogFor(req) {
++ const blog = await blogs.byHandle(req.params.handle);
if (!blog) return null;
-- return access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;
++ return await access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;
}
-- function postFor(req) {
-- const post = posts.get(req.params.id);
++ async function postFor(req) {
++ const post = await posts.get(req.params.id);
if (!post || post.state === 'deleted') return null;
-- const blog = posts.blogOf(post);
-- if (!access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;
++ const blog = await posts.blogOf(post);
++ if (!await access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;
return { post, blog };
}
@@@ -104,176 +104,176 @@ function createEditorRoutes(ctx) {
// ── Home, first use ─────────────────────────────────────
router.get('/', wrap(async (req, res) => {
-- const mine = blogs.blogsOf(req.viewer.subject);
-- const official = blogs.official();
++ const mine = await blogs.blogsOf(req.viewer.subject);
++ const official = await blogs.official();
if (req.viewer.editorial && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });
page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));
}));
router.post('/start', wrap(async (req, res) => {
const b = formInput(req.body);
-- const { blog } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });
++ const { blog } = await blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });
res.redirect(303, `/write/@${blog.handle}?saved=${encodeURIComponent('Your blog is ready.')}`);
}));
// ── A blog ──────────────────────────────────────────────
router.get('/@:handle', wrap(async (req, res) => {
-- const blog = blogFor(req);
++ const blog = await blogFor(req);
if (!blog) return notFound(req, res);
-- const list = posts.listForDashboard(blog, req.viewer);
-- const heads = new Map(list.map((p) => [p.id, (posts.head(p) || { fields: {} }).fields]));
-- page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));
++ const list = await posts.listForDashboard(blog, req.viewer);
++ const heads = new Map(await Promise.all(list.map(async (p) => [p.id, ((await posts.head(p)) || { fields: {} }).fields])));
++ page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: await access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));
}));
router.get('/@:handle/new', wrap(async (req, res) => {
-- const blog = blogFor(req);
-- if (!blog || !access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
++ const blog = await blogFor(req);
++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
const aiForm = ctx.aiDrafts && ctx.aiDrafts.enabled ? editor.aiDraftForm({ blog, csrf: csrf(req) }) : '';
page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${aiForm}${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);
}));
// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
router.post('/@:handle/ai-draft', wrap(async (req, res) => {
-- const blog = blogFor(req);
++ const blog = await blogFor(req);
if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);
const b = formInput(req.body);
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, { topic: b.topic, brief: b.brief, tone: b.tone, audience: b.audience }, { traceparent: req.ov && req.ov.traceparent });
-- effects.after(null, post, req.ov);
++ await effects.after(null, post, req.ov);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(`AI draft saved (revision ${revision.number}). It stays a draft, marked as AI-written, until you review and publish it.`)}`);
}));
router.post('/@:handle/new', wrap(async (req, res) => {
-- const blog = blogFor(req);
++ const blog = await blogFor(req);
if (!blog) return notFound(req, res);
-- const { post } = posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
-- effects.after(null, post, req.ov);
++ const { post } = await posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
++ await effects.after(null, post, req.ov);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent('Draft saved (revision 1).')}`);
}));
router.get('/@:handle/settings', wrap(async (req, res) => {
-- const blog = blogFor(req);
-- if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
-- const members = blogs.members(blog);
++ const blog = await blogFor(req);
++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
++ const members = await blogs.members(blog);
const who = await people.many(members.map((m) => m.subject));
-- page(req, res, 'Settings', editor.settingsPage({ blog, feed: blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));
++ page(req, res, 'Settings', editor.settingsPage({ blog, feed: await blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));
}));
router.post('/@:handle/settings', wrap(async (req, res) => {
-- const blog = blogFor(req);
-- if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
++ const blog = await blogFor(req);
++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
const b = formInput(req.body);
-- store.tx(() => {
-- const updated = blogs.update(blog, { title: b.title, description: b.description, language: b.language });
-- if (b.theme) blogs.setTheme(updated, b.theme);
-- blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });
++ await store.tx(async () => {
++ const updated = await blogs.update(blog, { title: b.title, description: b.description, language: b.language });
++ if (b.theme) await blogs.setTheme(updated, b.theme);
++ await blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });
});
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);
}));
router.post('/@:handle/members', wrap(async (req, res) => {
-- const blog = blogFor(req);
-- if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
++ const blog = await blogFor(req);
++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
const b = formInput(req.body);
const who = String(b.member || '').trim().replace(/^@/, '');
-- const subject = ids.isSubjectId('user', who) ? who : (people.byUsername(who) || {}).subject;
++ const subject = ids.isSubjectId('user', who) ? who : (await people.byUsername(who) || {}).subject;
if (!subject) return publicRoutes.messagePage(req, res, 404, 'Member not found', `Nobody called "${who}" has signed in to OpenVibe.Blog yet. Ask them to sign in once, or add them by their usr_ subject id.`, { href: `/write/@${blog.handle}/settings`, label: 'Back' });
-- blogs.setMember(blog, subject, b.role, req.viewer.subject);
++ await blogs.setMember(blog, subject, b.role, req.viewer.subject);
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member saved.')}`);
}));
router.post('/@:handle/members/remove', wrap(async (req, res) => {
-- const blog = blogFor(req);
-- if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
-- blogs.removeMember(blog, formInput(req.body).subject);
++ const blog = await blogFor(req);
++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
++ await blogs.removeMember(blog, formInput(req.body).subject);
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member removed.')}`);
}));
// ── A post ──────────────────────────────────────────────
router.get('/posts/:id', wrap(async (req, res) => {
-- const found = postFor(req);
++ const found = await postFor(req);
if (!found) return notFound(req, res);
const { post, blog } = found;
-- const head = posts.head(post);
-- const published = post.published_revision ? posts.revision(post, post.published_revision) : null;
++ const head = await posts.head(post);
++ const published = post.published_revision ? await posts.revision(post, post.published_revision) : null;
const rec = publication.authorshipOf(head);
-- const review = publication.reviewOf(post, head);
-- const terms = publication.termsOf(post);
-- const decision = publication.decide(blog, { ...post, state: 'published' }, head);
++ const review = await publication.reviewOf(post, head);
++ const terms = await publication.termsOf(post);
++ const decision = await publication.decide(blog, { ...post, state: 'published' }, head);
const body = editor.editPage({
-- blog, post, head, published, decision, jobs: posts.pendingJobs(post), attachments: posts.attachments(post), csrf: csrf(req),
++ blog, post, head, published, decision, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),
disclosure: rec ? authorship.disclosure(rec, review) : null,
needsReview: rec ? !authorship.canPublish(rec, review).ok : false,
publicUrl: publication.postPath(blog, post),
-- canDelete: access.canWrite(store, req.viewer, blog, 'delete', post),
-- form: editor.postForm({ blog, post, head, terms, series: blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
++ canDelete: await access.canWrite(store, req.viewer, blog, 'delete', post),
++ form: editor.postForm({ blog, post, head, terms, series: await blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
});
page(req, res, `Edit: ${head.fields.title}`, body);
}));
router.post('/posts/:id', wrap(async (req, res) => {
-- const found = postFor(req);
++ const found = await postFor(req);
if (!found) return notFound(req, res);
const before = { ...found.post };
-- const { post, revision, created } = posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
-- effects.after(before, post, req.ov);
++ const { post, revision, created } = await posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
++ await effects.after(before, post, req.ov);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(created ? `Saved as revision ${revision.number}.` : 'Saved (the text did not change, so no new revision).')}`);
}));
const action = (name, fn, message) => router.post(`/posts/:id/${name}`, wrap(async (req, res) => {
-- const found = postFor(req);
++ const found = await postFor(req);
if (!found && name !== 'unpublish' && name !== 'delete') return notFound(req, res);
-- const post = found ? found.post : posts.get(req.params.id);
++ const post = found ? found.post : await posts.get(req.params.id);
if (!post || post.state === 'deleted') return notFound(req, res);
const before = { ...post };
const out = await fn(req, post, formInput(req.body));
-- const after = posts.get(post.id);
-- effects.after(before, after, req.ov);
-- if (name === 'delete') return res.redirect(303, `/write/@${posts.blogOf(post).handle}?saved=${encodeURIComponent('Post deleted.')}`);
++ const after = await posts.get(post.id);
++ await effects.after(before, after, req.ov);
++ if (name === 'delete') return res.redirect(303, `/write/@${(await posts.blogOf(post)).handle}?saved=${encodeURIComponent('Post deleted.')}`);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(typeof message === 'function' ? message(out) : message)}`);
}));
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
-- action('publish', (req, post, b) => posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));
-- action('schedule', (req, post, b) => {
++ action('publish', async (req, post, b) => await posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));
++ action('schedule', async (req, post, b) => {
// <input type="datetime-local"> has no zone: the form says UTC, so it is read as UTC.
const raw = String(b.at || '');
const at = /^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(raw) ? `${raw}:00Z` : /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?$/.test(raw) ? `${raw}Z` : raw;
-- return posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });
++ return await posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });
}, (out) => `Scheduled for ${out.job.runAt}.`);
-- action('unschedule', (req, post) => posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');
-- action('unpublish', (req, post) => posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');
-- action('delete', (req, post, b) => {
++ action('unschedule', async (req, post) => await posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');
++ action('unpublish', async (req, post) => await posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');
++ action('delete', async (req, post, b) => {
if (b.confirm !== '1') throw Object.assign(new Error('Tick the box to confirm the deletion.'), { name: 'PublishingError', status: 422, code: 'post.confirm_required' });
-- return posts.remove(req.viewer, post, tp(req));
++ return await posts.remove(req.viewer, post, tp(req));
}, 'Deleted.');
-- action('review', (req, post, b) => posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);
-- action('revert', (req, post, b) => posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);
-- action('media', (req, post, b) => posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');
++ action('review', async (req, post, b) => await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);
++ action('revert', async (req, post, b) => await posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);
++ action('media', async (req, post, b) => await posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');
router.post('/posts/:id/media/:aid/remove', wrap(async (req, res) => {
-- const found = postFor(req);
++ const found = await postFor(req);
if (!found) return notFound(req, res);
-- posts.detach(req.viewer, found.post, req.params.aid);
++ await posts.detach(req.viewer, found.post, req.params.aid);
res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);
}));
router.get('/posts/:id/revisions', wrap(async (req, res) => {
-- const found = postFor(req);
++ const found = await postFor(req);
if (!found) return notFound(req, res);
const { post } = found;
-- const head = posts.head(post);
++ const head = await posts.head(post);
const from = parseInt(req.query.from, 10);
const to = parseInt(req.query.to, 10);
-- const diff = Number.isInteger(from) && Number.isInteger(to) ? posts.diff(post, from, to, 'word') : null;
-- page(req, res, 'History', editor.revisionsPage({ post, head, revisions: posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));
++ const diff = Number.isInteger(from) && Number.isInteger(to) ? await posts.diff(post, from, to, 'word') : null;
++ page(req, res, 'History', editor.revisionsPage({ post, head, revisions: await posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));
}));
router.get('/posts/:id/preview', wrap(async (req, res) => {
-- const found = postFor(req);
++ const found = await postFor(req);
if (!found) return notFound(req, res);
const n = parseInt(req.query.revision, 10);
-- const rev = Number.isInteger(n) ? posts.revision(found.post, n) : posts.head(found.post);
++ const rev = Number.isInteger(n) ? await posts.revision(found.post, n) : await posts.head(found.post);
if (!rev) return notFound(req, res);
return publicRoutes.renderPost(req, res, { blog: found.blog, post: found.post, rev, preview: true });
}));
ddiff --git a/server/http/public.js b/server/http/public.js
iindex eed6d4c..53f4716 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -61,15 +61,15 @@ function createPublicRoutes(ctx) {
}
const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'The OpenVibe blog' });
-- const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
++ const isMember = async (blog, viewer) => Boolean(await access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
function pageNumber(req) {
const n = parseInt(req.query.page, 10);
return Number.isInteger(n) && n > 0 ? n : 1;
}
-- function activeBlog(req) {
-- const blog = blogs.byHandle(req.params.handle);
++ async function activeBlog(req) {
++ const blog = await blogs.byHandle(req.params.handle);
if (!blog) return null;
if (blog.status !== 'active' && !access.isStaff(req.viewer)) return null;
return blog;
@@@ -81,19 +81,27 @@ function createPublicRoutes(ctx) {
async function blogFront(req, res, blog, { home = false } = {}) {
const page = pageNumber(req);
-- const restricted = isMember(blog, req.viewer);
++ const restricted = await isMember(blog, req.viewer);
const path = publication.blogPath(blog);
-- const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
++ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
if (pager.outOfRange && total) return notFound(req, res);
const items = await reading.listItems(rows);
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
-- const feeds = reading.feedsOf(blog);
++ const feeds = await reading.feedsOf(blog);
// Series and categories are named when a draft or restricted post uses them: list only those
// holding a post this viewer may see in the list, so their names never leak ahead of a post.
-- const shows = (filter) => posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 }).total > 0;
-- const prune = (nodes) => nodes.map((n) => ({ ...n, children: prune(n.children || []) }))
-- .filter((n) => n.children.length || shows({ termIds: [n.id] }));
++ const shows = async (filter) => (await posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 })).total > 0;
++ const prune = async (nodes) => {
++ const out = [];
++ for (const n of nodes) {
++ const children = await prune(n.children || []);
++ if (children.length || await shows({ termIds: [n.id] })) out.push({ ...n, children });
++ }
++ return out;
++ };
++ const seriesShown = [];
++ for (const sr of await blogs.series(blog)) if (await shows({ seriesId: sr.id })) seriesShown.push(sr);
send(req, res, 200, {
title: blog.title,
description: blog.description,
@@@ -109,13 +117,13 @@ function createPublicRoutes(ctx) {
: { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],
body: pages.blogIndex({
blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
-- series: blogs.series(blog).filter((sr) => shows({ seriesId: sr.id })), categories: prune(reading.categoriesTree(blog)),
-- canWrite: access.canWrite(store, req.viewer, blog, 'create'),
++ series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),
++ canWrite: await access.canWrite(store, req.viewer, blog, 'create'),
}) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),
}, { cacheable: !restricted });
}
-- router.get('/', wrap(async (req, res) => blogFront(req, res, blogs.official(), { home: true })));
++ router.get('/', wrap(async (req, res) => await blogFront(req, res, await blogs.official(), { home: true })));
// What shipped on OpenVibe.Blog: the shared update log (openvibe-shared shipped.js, fed by the
// network changelog this service keeps), like every OpenVibe site's /updates.
@@@ -128,16 +136,16 @@ function createPublicRoutes(ctx) {
}, { cacheable: true })));
router.get('/@:handle', wrap(async (req, res) => {
-- const blog = activeBlog(req);
++ const blog = await activeBlog(req);
if (!blog) return notFound(req, res);
if (blog.kind === 'official') return res.redirect(301, '/');
-- return blogFront(req, res, blog);
++ return await blogFront(req, res, blog);
}));
// ── Feeds (public posts only, never viewer-dependent) ───
async function feed(req, res, blog, kind) {
-- const s = blogs.feedSettings(blog);
++ const s = await blogs.feedSettings(blog);
if (!s[kind]) return notFound(req, res);
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const link = publication.abs(publication.blogPath(blog));
@@@ -152,12 +160,12 @@ function createPublicRoutes(ctx) {
}
const FEEDS = { 'feed.xml': 'rss', 'atom.xml': 'atom', 'feed.json': 'json' };
for (const [file, kind] of Object.entries(FEEDS)) {
-- router.get(`/${file}`, wrap((req, res) => feed(req, res, blogs.official(), kind)));
-- router.get(`/@:handle/${file}`, wrap((req, res) => {
-- const blog = activeBlog(req);
++ router.get(`/${file}`, wrap(async (req, res) => await feed(req, res, await blogs.official(), kind)));
++ router.get(`/@:handle/${file}`, wrap(async (req, res) => {
++ const blog = await activeBlog(req);
if (!blog) return notFound(req, res);
if (blog.kind === 'official') return res.redirect(301, `/${file}`);
-- return feed(req, res, blog, kind);
++ return await feed(req, res, blog, kind);
}));
}
@@@ -165,8 +173,8 @@ function createPublicRoutes(ctx) {
async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {
const page = pageNumber(req);
-- const restricted = blog ? isMember(blog, req.viewer) : false;
-- const { total, posts: rows } = posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
++ const restricted = blog ? await isMember(blog, req.viewer) : false;
++ const { total, posts: rows } = await posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
// A series or category named only by drafts or posts this viewer cannot see does not exist for them.
if (hideEmpty && !total) return notFound(req, res);
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
@@@ -186,56 +194,56 @@ function createPublicRoutes(ctx) {
const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });
router.get('/@:handle/tags/:tag', wrap(async (req, res) => {
-- const blog = activeBlog(req);
-- const tag = blog && store.taxonomy.bySlug('tag', req.params.tag);
++ const blog = await activeBlog(req);
++ const tag = blog && await store.taxonomy.bySlug('tag', req.params.tag);
if (!tag) return notFound(req, res);
-- return collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),
++ return await collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),
crumbs: [blogCrumb(blog), { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
}));
router.get('/tags/:tag', wrap(async (req, res) => {
-- const tag = store.taxonomy.bySlug('tag', req.params.tag);
++ const tag = await store.taxonomy.bySlug('tag', req.params.tag);
if (!tag) return notFound(req, res);
-- return collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),
++ return await collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),
crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
}));
router.get('/@:handle/categories/:category', wrap(async (req, res) => {
-- const blog = activeBlog(req);
-- const term = blog && store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);
++ const blog = await activeBlog(req);
++ const term = blog && await store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);
if (!term) return notFound(req, res);
-- const trail = store.taxonomy.ancestors(term.id);
-- const ids = [term.id, ...store.taxonomy.descendants(term.id).map((t) => t.id)];
-- return collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),
++ const trail = await store.taxonomy.ancestors(term.id);
++ const ids = [term.id, ...(await store.taxonomy.descendants(term.id)).map((t) => t.id)];
++ return await collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),
crumbs: [blogCrumb(blog), ...trail.map((t, i) => (i === trail.length - 1 ? { name: t.name } : { name: t.name, url: reading.urls.category(blog, t) }))],
filter: { termIds: ids }, empty: 'No public posts in this category yet.', hideEmpty: true });
}));
router.get('/@:handle/series/:series', wrap(async (req, res) => {
-- const blog = activeBlog(req);
-- const series = blog && blogs.seriesBySlug(blog, req.params.series);
++ const blog = await activeBlog(req);
++ const series = blog && await blogs.seriesBySlug(blog, req.params.series);
if (!series) return notFound(req, res);
-- return collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),
++ return await collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),
crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.', hideEmpty: true });
}));
async function personFor(who) {
-- if (/^usr_/.test(who)) return people.one(who);
-- return people.byUsername(who);
++ if (/^usr_/.test(who)) return await people.one(who);
++ return await people.byUsername(who);
}
router.get('/@:handle/authors/:who', wrap(async (req, res) => {
-- const blog = activeBlog(req);
++ const blog = await activeBlog(req);
const person = blog && await personFor(req.params.who);
-- if (!person || !blogs.membership(blog, person.subject) && !posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 }).total) return notFound(req, res);
-- return collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),
++ if (!person || !await blogs.membership(blog, person.subject) && !(await posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 })).total) return notFound(req, res);
++ return await collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),
crumbs: [blogCrumb(blog), { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
}));
router.get('/authors/:who', wrap(async (req, res) => {
const person = await personFor(req.params.who);
if (!person) return notFound(req, res);
-- return collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),
++ return await collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),
crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
}));
@@@ -256,28 +264,29 @@ function createPublicRoutes(ctx) {
}
}
-- function seriesView(blog, post) {
-- const series = blogs.seriesById(post.series_id);
++ async function seriesView(blog, post) {
++ const series = await blogs.seriesById(post.series_id);
if (!series) return null;
-- const { posts: rows } = posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });
++ const { posts: rows } = await posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });
++ const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));
return {
title: series.title, url: reading.urls.series(blog, series), position: post.series_position,
-- items: rows.map((p) => ({ title: (store.revisions.get(p.id, p.published_revision) || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),
++ items: rows.map((p, i) => ({ title: (revs[i] || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),
};
}
/** Render one post (also used by the editor's preview with a chosen revision). */
async function renderPost(req, res, { blog, post, rev, preview = false, status = 200 }) {
-- const decision = preview ? publication.decide(blog, post, rev, { state: 'draft' }) : publication.decide(blog, post, rev);
++ const decision = preview ? await publication.decide(blog, post, rev, { state: 'draft' }) : await publication.decide(blog, post, rev);
const person = await people.one(post.author_subject);
-- const { tags, categories } = reading.termsView(post, blog);
-- const attachments = store.attachments.list(post.id);
-- const citations = store.citations.forRevision(post.id, rev.number);
++ const { tags, categories } = await reading.termsView(post, blog);
++ const attachments = await store.attachments.list(post.id);
++ const citations = await store.citations.forRevision(post.id, rev.number);
const rec = publication.authorshipOf(rev);
-- const review = publication.reviewOf(post, rev);
++ const review = await publication.reviewOf(post, rev);
const path = publication.postPath(blog, post);
const firstCategory = categories[0];
-- const crumbs = [blogCrumb(blog), ...(firstCategory ? store.taxonomy.ancestors(firstCategory.id).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
++ const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
const comments = preview ? null : await commentsFor(req, blog, post, rev);
const cacheable = !preview && post.state === 'published' && post.visibility === 'public';
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
@@@ -291,17 +300,17 @@ function createPublicRoutes(ctx) {
image: (attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId),
published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
modified: post.published_revision ? rev.createdAt : null,
-- feeds: reading.feedsOf(blog),
++ feeds: await reading.feedsOf(blog),
theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
lang: blog.language,
jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],
body: pages.postPage({
blog, blogUrl: publication.blogPath(blog), post, rev,
author: { name: person.name, url: reading.urls.author(person, blog) },
-- tags, categories, series: seriesView(blog, post), attachments, citations,
++ tags, categories, series: await seriesView(blog, post), attachments, citations,
disclosure: rec ? authorship.disclosure(rec, review) : null,
decisionNote: preview ? `preview · ${decision.robots}` : null,
-- comments, canEdit: access.canWrite(store, req.viewer, blog, 'edit', post),
++ comments, canEdit: await access.canWrite(store, req.viewer, blog, 'edit', post),
urlFor: media.urlFor, breadcrumbs: crumbs, jsonUrl: `${path}.json`,
postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),
loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,
@@@ -330,22 +339,22 @@ function createPublicRoutes(ctx) {
/** Resolve /@handle/:slug to a readable post, or answer (301 / 404 / 403 / 410) and return null. */
async function resolvePost(req, res, blog, slug) {
-- const post = posts.bySlug(blog, slug);
++ const post = await posts.bySlug(blog, slug);
if (!post) {
const path = `/@${blog.handle}/${slug}`;
-- const r = store.redirects.resolve(path, {
-- currentPath: (id) => { const p = posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(blogs.get(p.blog_id), p) : null; },
++ const r = await store.redirects.resolve(path, {
++ currentPath: async (id) => { const p = await posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(await blogs.get(p.blog_id), p) : null; },
});
if (r && r.status === 301) {
-- const target = posts.get(r.entityId);
-- const decision = await access.canReadPost(store, req.viewer, blogs.get(target.blog_id), target, entitlements);
++ const target = await posts.get(r.entityId);
++ const decision = await access.canReadPost(store, req.viewer, await blogs.get(target.blog_id), target, entitlements);
if (decision.allowed) {
cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });
res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));
return null;
}
}
-- if ((r && r.status === 410) || posts.deletedBySlug(blog, slug)) {
++ if ((r && r.status === 410) || await posts.deletedBySlug(blog, slug)) {
messagePage(req, res, 410, 'Gone', 'This post was deleted.', { href: publication.blogPath(blog), label: `More from ${blog.title}` });
return null;
}
@@@ -362,27 +371,27 @@ function createPublicRoutes(ctx) {
}
router.get('/@:handle/:slug', wrap(async (req, res) => {
-- const blog = activeBlog(req);
++ const blog = await activeBlog(req);
if (!blog) return notFound(req, res);
const asJson = req.params.slug.endsWith('.json');
const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
const post = await resolvePost(req, res, blog, slug);
if (!post) return;
// Readers see the published revision; members looking at an unpublished post see its head.
-- const rev = post.state === 'published' ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);
++ const rev = post.state === 'published' ? await store.revisions.get(post.id, post.published_revision) : await store.revisions.head(post.id);
if (asJson) {
-- const decision = publication.decide(blog, post, rev);
++ const decision = await publication.decide(blog, post, rev);
cacheHeaders(res, { cacheable: post.state === 'published' && post.visibility === 'public' && req.viewer.kind === 'anonymous', robots: decision.robots });
-- return res.json(reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));
++ return res.json(await reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));
}
-- return renderPost(req, res, { blog, post, rev });
++ return await renderPost(req, res, { blog, post, rev });
}));
// Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in
// the person's name.
router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
-- const blog = activeBlog(req);
-- const post = blog && posts.bySlug(blog, req.params.slug);
++ const blog = await activeBlog(req);
++ const post = blog && await posts.bySlug(blog, req.params.slug);
if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);
if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(publication.postPath(blog, post))}`);
if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
@@@ -390,7 +399,7 @@ function createPublicRoutes(ctx) {
const message = String((req.body && req.body.message) || '').trim();
if (!message) return res.redirect(303, `${publication.postPath(blog, post)}#comments`);
try {
-- const rev = store.revisions.get(post.id, post.published_revision);
++ const rev = await store.revisions.get(post.id, post.published_revision);
const threadId = await community.threadFor(post, rev && rev.fields.title, req.ov);
await community.comment(threadId, req.viewer.subject, { message }, req.ov);
} catch (err) {
ddiff --git a/server/index.js b/server/index.js
iindex 8fbc0e7..6659a2c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -7,11 +7,12 @@
*/
const { createApp } = require('./app');
--const { app, ctx } = createApp();
++(async () => {
++const { app, ctx } = await createApp();
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
-- console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
++ console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Blog] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
});
server.keepAliveTimeout = 65_000;
@@@ -26,10 +27,11 @@ function shutdown(signal) {
ctx.changelog.stop();
server.close(async () => {
try { await ctx.outbox.stop(); } catch { /* best effort */ }
-- try { ctx.store.close(); } catch { /* already closed */ }
++ try { await ctx.store.close(); } catch { /* already closed */ }
process.exit(0);
});
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
++})().catch((err) => { console.error('[Blog] failed to start:', err); process.exit(1); });
ddiff --git a/server/observability.js b/server/observability.js
iindex 80dbd24..3b62210 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -14,7 +14,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { CHARTER_TABLES } = require('./db');
--function createBlogReadiness({ store, auth, outbox, release = null }) {
++function createBlogReadiness({ store, auth, outbox, valkey = null, release = null }) {
const { db } = store;
return createReadiness({
service: 'blog',
@@@ -22,12 +22,16 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
checks: [
{
name: 'db', required: true,
-- check: () => {
-- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
++ check: async () => {
++ // A real round trip that names the store (postgresql / pglite), and the charter tables and views present.
++ const r = await db.ready();
++ if (!r.ok) return r.error;
++ const names = new Set((await db.prepare('SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()').all()).map((x) => x.name));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
-- return missing.length ? `missing ${missing.join(', ')}` : true;
++ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
},
},
++ { 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: () => {
@@@ -38,8 +42,8 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
},
{
name: 'events_relay', required: false,
-- check: () => {
-- const s = outbox.status();
++ check: async () => {
++ const s = await outbox.status();
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
return { ok: true, detail: { pending: s.pending } };
@@@ -47,9 +51,9 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
},
{
name: 'scheduler', required: false,
-- check: () => {
-- const failed = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000).n;
-- const overdue = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000).n;
++ check: async () => {
++ const failed = (await db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000)).n;
++ const overdue = (await db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000)).n;
if (failed || overdue) return `${failed} failed in the last 24 h, ${overdue} overdue`;
return true;
},
ddiff --git a/server/seed.js b/server/seed.js
iindex 6c9a8bc..e9eeee6 100644
---- a/server/seed.js
++++ b/server/seed.js
@@@ -48,19 +48,19 @@ function composeBody(facts) {
* Create the draft (idempotent by slug). opts: author (usr_ subject accountable for the post;
* default the first official owner), reviewer (usr_), publish (bool), facts (parsed file).
*/
--function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {
++async function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {
const { blogs, posts, store } = ctx;
-- const blog = blogs.ensureOfficial();
-- const owners = blogs.members(blog).filter((m) => m.role === 'owner').map((m) => m.subject);
++ const blog = await blogs.ensureOfficial();
++ const owners = (await blogs.members(blog)).filter((m) => m.role === 'owner').map((m) => m.subject);
const subject = author || owners[0];
if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');
if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');
const actor = { kind: 'user', subject, staff: true, editorial: true, user: {} };
-- let post = posts.bySlug(blog, SLUG);
++ let post = await posts.bySlug(blog, SLUG);
let created = false;
if (!post) {
-- const out = posts.create(actor, blog, {
++ const out = await posts.create(actor, blog, {
title: `Released in the OpenVibe repositories, ${facts.week.from.slice(8)}–${facts.week.to.slice(8)} September 2026`,
slug: SLUG,
summary: `Commit messages and release notes quoted from the OpenVibers repositories for ${facts.week.from} to ${facts.week.to}, each with a link to its source.`,
@@@ -77,11 +77,11 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
let review = null;
let published = false;
if (publish) {
-- const head = store.revisions.head(post.id);
-- review = posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });
-- published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;
++ const head = await store.revisions.head(post.id);
++ review = await posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });
++ published = (await posts.publish(actor, await posts.get(post.id), { revision: head.number })).changed;
}
-- return { post: posts.get(post.id), created, review, published };
++ return { post: await posts.get(post.id), created, review, published };
}
module.exports = { seedOfficialPost, loadFacts, composeBody, SLUG };
ddiff --git a/server/worker.js b/server/worker.js
iindex d20bec1..8f2150d 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -19,7 +19,7 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
running = true;
try {
const summary = await posts.runScheduled(config.worker.id);
-- if (summary.done.length || summary.failed.length) outbox.kick();
++ if (summary.done.length || summary.failed.length) await outbox.kick();
return summary;
} catch (err) {
log.error('[Blog] schedule tick failed:', err.message);
@@@ -30,8 +30,8 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
/** Verify the attachments of published posts (at most `limit` posts per run). */
async function verifyMedia({ limit = 200 } = {}) {
if (!media.enabled) return [];
-- const ids = store.db.prepare(`SELECT DISTINCT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id
-- WHERE p.state = 'published' ORDER BY COALESCE(a.checked_at, 0) LIMIT ?`).all(limit).map((r) => r.id);
++ const ids = (await store.db.prepare(`SELECT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id
++ WHERE p.state = 'published' GROUP BY a.entity_id ORDER BY MIN(COALESCE(a.checked_at, 0)), a.entity_id LIMIT ?`).all(limit)).map((r) => r.id);
const out = [];
for (const id of ids) {
const results = await store.attachments.verify(id, { resolve: media.resolve });
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 6c14bb4..80dfa60 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -73,12 +73,12 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
r = await t.get(`/api/v1/posts/${id}/revert`, { as: fay, json: { to_revision: 1, expected_revision: rev } });
assert.strictEqual(r.status, 201, `the 30th, a revert: ${r.text}`);
rev = r.json().revision;
-- const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id).n;
-- const before = stored();
++ const stored = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id)).n;
++ const before = await stored();
r = await t.get(`/write/posts/${id}`, { as: fay, form: { _csrf: t.csrf({ subject: fay.subject }), title: 'Counted', body: `${LONG} one more`, expectedRevision: String(rev) } });
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.ok(r.json().detail.includes('blog.post.update'), r.json().detail);
-- assert.strictEqual(stored(), before, 'nothing stored');
++ assert.strictEqual(await stored(), before, 'nothing stored');
r = await t.get('/api/v1/blogs/gus/posts', { as: gus, json: { title: 'Mine', body: LONG } });
assert.strictEqual(r.status, 201, 'another person still writes');
r = await t.get(`/api/v1/posts/${r.json().post.id}`, { as: gus, method: 'PATCH', json: { expected_revision: r.json().revision, body: `${LONG} edited` } });
ddiff --git a/test/ai-draft.test.js b/test/ai-draft.test.js
iindex 3706926..14db546 100644
---- a/test/ai-draft.test.js
++++ b/test/ai-draft.test.js
@@@ -46,7 +46,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.deepStrictEqual(asked[0].body.on_behalf_of, { type: 'user', id: fay.subject });
assert.strictEqual(asked[0].body.target.service, 'blog');
assert.ok(/^Bearer /.test(asked[0].auth), 'Blog\'s service token');
-- const cites = t.ctx.posts.citations(t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(draft.id) || { id: draft.id }, 1);
++ const cites = await t.ctx.posts.citations(await t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(draft.id) || { id: draft.id }, 1);
assert.ok(cites.some((c) => c.url === 'https://example.org/self-hosting'), 'the run\'s citations are on revision 1');
});
@@@ -65,11 +65,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
await check('a failed run makes no post; a stranger cannot ask', async () => {
mode = 'fail';
-- const before = t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n;
++ const before = (await t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get()).n;
const r = await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: fay, json: { topic: 'x' } });
assert.strictEqual(r.status, 502);
assert.strictEqual(r.json().code, 'ai.run_failed');
-- assert.strictEqual(t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n, before);
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get()).n, before);
mode = 'ok';
const stranger = t.network.addUser('stranger');
assert.strictEqual((await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: stranger, json: { topic: 'x' } })).status, 403);
ddiff --git a/test/api-ai.test.js b/test/api-ai.test.js
iindex 0cd56e1..1af6ebd 100644
---- a/test/api-ai.test.js
++++ b/test/api-ai.test.js
@@@ -48,7 +48,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
assert.strictEqual(draft.authorship.mode, 'ai');
assert.strictEqual(draft.authorship.workflow.id, 'blog.draft_post');
assert.deepStrictEqual(draft.authorship.authors, [], 'AI output is never attributed to the person');
-- const created = t.events('blog.post.created')[0];
++ const created = (await t.events('blog.post.created'))[0];
assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });
assert.strictEqual(created.payload.authorship, 'ai');
const noWorkflow = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'No run', body: LONG } });
@@@ -79,7 +79,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
const page = await t.get('/@fay/an-ai-draft');
assert.match(page.text, /AI-generated/);
assert.match(page.text, /reviewed by a person/);
-- const doc = t.events('blog.index_document.upserted').pop().payload;
++ const doc = (await t.events('blog.index_document.upserted')).pop().payload;
assert.strictEqual(doc.authorship, 'ai_generated');
assert.ok(doc.provenance.some((r) => r.service === 'ai' && r.type === 'run' && r.id === WORKFLOW.runId));
});
ddiff --git a/test/changelog.test.js b/test/changelog.test.js
iindex afc8f5c..4779644 100644
---- a/test/changelog.test.js
++++ b/test/changelog.test.js
@@@ -18,21 +18,21 @@ const H = (n) => n.toString(16).padStart(40, 'a');
const fetchImpl = async (url) => {
const u = new URL(url);
const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
-- if (u.pathname === '/api/v1/registry/services') return json({ services: [
++ if (u.pathname === '/api/v1/registry/services') return await json({ services: [
{ id: 'live', name: 'OpenVibe.Live', repository: 'OpenVibers/OpenVibe.Live', publicOrigin: 'https://openvibe.live' },
{ id: 'tools', name: 'OpenVibe.Tools', repository: 'OpenVibers/OpenVibe.Tools', publicOrigin: 'https://openvibe.tools' },
{ id: 'norepo', name: 'X' },
] });
-- if (u.pathname === '/api/v1/registry/releases') return json({ checked_at: null, libraries: [], behind: [], services: Object.entries(releases).map(([id, r]) => ({ id, release: r, released_at: new Date(t.clock.now()).toISOString() })) });
++ if (u.pathname === '/api/v1/registry/releases') return await json({ checked_at: null, libraries: [], behind: [], services: Object.entries(releases).map(([id, r]) => ({ id, release: r, released_at: new Date(t.clock.now()).toISOString() })) });
if (u.hostname === 'api.github.com') {
ghCalls.push(u.pathname);
const m = u.pathname.match(/^\/repos\/(.+)\/compare\/(.+)\.\.\.(.+)$/);
-- if (m && compares[m[3]]) return json(compares[m[3]]);
++ if (m && compares[m[3]]) return await json(compares[m[3]]);
const h = u.pathname.match(/^\/repos\/(.+)\/commits$/);
-- if (h && history[h[1]]) return json(history[h[1]]);
-- return json({ message: 'Not Found' }, 404);
++ if (h && history[h[1]]) return await json(history[h[1]]);
++ return await json({ message: 'Not Found' }, 404);
}
-- return json({}, 404);
++ return await json({}, 404);
};
const config = { ...t.ctx.config, changelog: { ...t.ctx.config.changelog, enabled: true, batchSize: 5, majorLines: 500, quietMs: 30 * 60 * 1000, maxAgeMs: 7 * 24 * 3600 * 1000, minGapMs: 6 * 3600 * 1000, since: null, aiDraft: false, blogHandle: 'openvibe', history: 0 } };
const cl = createChangelog({ config, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl, log: { log() {}, warn() {} } });
@@@ -40,7 +40,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
await check('a service seen for the first time only records its release', async () => {
await cl.tick();
-- assert.strictEqual(cl.entries().length, 0);
++ assert.strictEqual((await cl.entries()).length, 0);
assert.deepStrictEqual(ghCalls, [], 'no history dump');
});
@@@ -49,7 +49,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
compares.aaaaaaa00002 = { status: 'ahead', commits: [commit(1, 'Fix the chat scroll\n\nbody text'), commit(2, 'Merge branch main')], files: [{ additions: 10, deletions: 2 }] };
const r = await cl.tick();
assert.strictEqual(r, null, 'not due yet');
-- const e = cl.entries({ service: 'live' });
++ const e = await cl.entries({ service: 'live' });
assert.deepStrictEqual(e.map((x) => x.subject), ['Fix the chat scroll'], 'first lines only; merges skipped');
assert.strictEqual(e[0].url, `https://github.com/OpenVibers/OpenVibe.Live/commit/${H(1)}`);
assert.strictEqual(e[0].major, false);
@@@ -62,14 +62,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');
t.clock.advance(31 * 60 * 1000);
const r = await cl.tick();
assert.ok(r && r.post, 'published');
-- const post = t.ctx.posts.get(r.post.id);
++ const post = await t.ctx.posts.get(r.post.id);
assert.strictEqual(post.state, 'published');
-- const head = t.ctx.posts.head(post);
++ const head = await t.ctx.posts.head(post);
assert.strictEqual(head.fields.title, 'Patch notes: Recent tools in the launcher');
assert.ok(head.content.includes('## Highlights') && head.content.includes('**OpenVibe.Tools:** Recent tools in the launcher'));
assert.ok(head.content.includes('## OpenVibe.Live') && head.content.includes('Fix the chat scroll'));
assert.strictEqual(head.meta.authorship.mode, 'imported');
-- assert.strictEqual(cl.entries().filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');
++ assert.strictEqual((await cl.entries()).filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');
});
await check('the public feed names the entries and the latest post', async () => {
@@@ -90,7 +90,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
t.clock.advance(6 * 3600 * 1000 + 1000);
const r = await cl.tick();
assert.ok(r && r.post);
-- assert.ok(t.ctx.posts.head(t.ctx.posts.get(r.post.id)).fields.title.startsWith('Patch notes: 5 changes across 1 site'));
++ assert.ok((await t.ctx.posts.head(await t.ctx.posts.get(r.post.id))).fields.title.startsWith('Patch notes: 5 changes across 1 site'));
});
await check('subjects are cut at a word; version bumps count as major', async () => {
@@@ -100,26 +100,26 @@ const H = (n) => n.toString(16).padStart(40, 'a');
releases.tools = 'bbbbbbb00003';
compares.bbbbbbb00003 = { status: 'ahead', commits: [commit(10, '1.7.0: openvibe-shared/trace')], files: [{ additions: 3, deletions: 1 }] };
await cl.tick();
-- assert.strictEqual(cl.entries({ service: 'tools' })[0].major, true);
++ assert.strictEqual((await cl.entries({ service: 'tools' }))[0].major, true);
});
await check('history: each site\'s earlier commits once, never pending, with authors; the feed pages by cursor', async () => {
history['OpenVibers/OpenVibe.Live'] = [20, 21, 22].map((i) => ({ sha: H(i), commit: { message: `Earlier change ${i}\n\nbody`, author: { name: 'OpenVibers', date: new Date(t.clock.now() - (30 - i) * 86400000).toISOString() } } }))
.concat([{ sha: H(5), commit: { message: 'Small change 5', author: { name: 'OpenVibers', date: new Date(t.clock.now()).toISOString() } } }]);
const hc = createChangelog({ config: { ...config, changelog: { ...config.changelog, history: 50 } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, fetchImpl, log: { log() {}, warn() {} } });
-- const before = hc.stats().pending;
++ const before = (await hc.stats()).pending;
await hc.collect();
-- assert.strictEqual(hc.stats().pending, before, 'history never waits for a patch notes post');
-- const all = hc.entries({ service: 'live', limit: 100 });
++ assert.strictEqual((await hc.stats()).pending, before, 'history never waits for a patch notes post');
++ const all = await hc.entries({ service: 'live', limit: 100 });
assert.ok(all.some((e) => e.subject === 'Earlier change 20' && e.author === 'OpenVibers' && e.post_id === null));
assert.strictEqual(all.find((e) => e.sha === H(5)).author, 'OpenVibers', 'a known commit gains its author');
const calls = ghCalls.length;
await hc.collect();
assert.strictEqual(ghCalls.length, calls, 'imported once (a repository GitHub will not list is not asked again either)');
-- const p1 = hc.page({ service: 'live', limit: 2 });
++ const p1 = await hc.page({ service: 'live', limit: 2 });
assert.strictEqual(p1.entries.length, 2);
assert.ok(p1.next);
-- const p2 = hc.page({ service: 'live', limit: 2, before: p1.next });
++ const p2 = await hc.page({ service: 'live', limit: 2, before: p1.next });
assert.ok(p2.entries.length >= 1 && !p2.entries.some((e) => p1.entries.some((x) => x.sha === e.sha)), 'the next page continues without repeats');
const r = (await t.get(`/api/v1/changelog?limit=2&before=${encodeURIComponent(p1.next)}`)).json();
assert.ok(Array.isArray(r.sites) && r.sites.some((x) => x.service === 'live'), 'network-wide answers list the sites');
@@@ -135,14 +135,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');
const fx = async (url, opts = {}) => {
const u = new URL(url);
const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
-- if (u.pathname === '/oauth/token') { seen.oauth.push(String(opts.body)); return json({ access_token: 'svc-tok', token_type: 'Bearer', expires_in: 300 }); }
++ if (u.pathname === '/oauth/token') { seen.oauth.push(String(opts.body)); return await json({ access_token: 'svc-tok', token_type: 'Bearer', expires_in: 300 }); }
if (u.pathname === '/internal/integrations/github-token') {
seen.network++;
assert.strictEqual(opts.headers.Authorization || opts.headers.authorization, 'Bearer svc-tok');
-- return json(networkAnswer.body, networkAnswer.status);
++ return await json(networkAnswer.body, networkAnswer.status);
}
if (u.hostname === 'api.github.com') seen.auth.push((opts.headers && opts.headers.Authorization) || null);
-- return fetchImpl(url, opts);
++ return await fetchImpl(url, opts);
};
const mk = (extra, oauth = { clientId: 'blog', clientSecret: 's3cret' }) => createChangelog({ config: { ...config, oauth, networkInternalUrl: 'http://network.internal', changelog: { ...config.changelog, ...extra } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl: fx, log: { log() {}, warn() {} } });
const release = async (cl2, n) => { releases.live = `aaaaaaa1000${n}`; compares[releases.live] = { status: 'ahead', commits: [commit(900 + n, `Token check ${n}`)], files: [{ additions: 1, deletions: 0 }] }; await cl2.collect(); };
@@@ -151,7 +151,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
await release(net, 1);
assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
assert.ok(seen.oauth[0].includes('audience=openvibe.network') && seen.oauth[0].includes('network.integration.github.read'));
-- assert.strictEqual(net.stats().github_token, 'network');
++ assert.strictEqual((await net.stats()).github_token, 'network');
await release(net, 2);
assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
assert.strictEqual(seen.network, 1, 'kept ten minutes');
@@@ -160,7 +160,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
t.clock.advance(11 * 60 * 1000);
await release(net, 3);
assert.strictEqual(seen.auth.pop(), null, 'none set: anonymous');
-- assert.strictEqual(net.stats().github_token, 'none');
++ assert.strictEqual((await net.stats()).github_token, 'none');
const env = mk({ githubToken: 'ghp_fromenv' });
const before = seen.network;
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
iindex 8e2f6cc..1f46f60 100644
---- a/test/delegation.test.js
++++ b/test/delegation.test.js
@@@ -20,10 +20,10 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
const secret = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Private minutes', body: `Minutes ${LONG}`, visibility: 'private' } })).json().post;
const caps = ['blog.post.read', 'blog.post.create'];
const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.blog'], cap: caps, extra });
-- const postsOf = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n;
++ const postsOf = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n;
await check('an app or module cannot act for someone else by naming them in X-OV-Subject', async () => {
-- const before = postsOf();
++ const before = await postsOf();
for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
for (const extra of [{ on_behalf_of: appUser.subject }, {}]) {
const read = await t.get(`/api/v1/posts/${secret.id}`, { as: token(sub, type, extra), headers: { 'x-ov-subject': carol.subject } });
@@@ -34,7 +34,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
assert.strictEqual(write.status, 403, `${type} write: ${write.text}`);
}
}
-- assert.strictEqual(postsOf(), before, 'nothing written as carol');
++ assert.strictEqual(await postsOf(), before, 'nothing written as carol');
});
await check('an app acts for its on_behalf_of person, with that person\'s rights', async () => {
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 486cbfa..d24273f 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -40,11 +40,14 @@ async function boot(opts = {}) {
const { createApp } = require('../../server/app');
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
++ const { createStore } = require('../../server/db');
++ // One database per boot (PGlite, or BLOG_TEST_STORE=pg: the containers); a restart keeps it, like a file did.
++ const testdb = await require('./db').testDb();
let server = null;
let built = null;
async function start() {
const config = configLib.load(env);
-- built = createApp({ config, now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });
++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });
await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
@@@ -53,7 +56,7 @@ async function boot(opts = {}) {
}
async function stop() {
if (server) await new Promise((r) => server.close(r));
-- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }
server = null; built = null;
}
@@@ -71,15 +74,15 @@ async function boot(opts = {}) {
}
/** Rows of event_outbox as parsed envelopes. */
-- function events(type = null) {
-- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
++ async function events(type = null) {
++ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
}
const t = {
network, community, media, clock, dbPath, official, get, events,
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.BLOG_FORM_SECRET }, user),
async restart() { await stop(); await start(); },
-- async close() { await stop(); await network.close(); await community.close(); await media.close(); fs.rmSync(dir, { recursive: true, force: true }); },
++ async close() { await stop(); await testdb.close(); await network.close(); await community.close(); await media.close(); fs.rmSync(dir, { recursive: true, force: true }); },
};
await start();
return t;
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..b64e292
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * BLOG_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
++ */
++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../../server/db');
++
++const testDb = ({ store = process.env.BLOG_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'blog', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
iindex 0e343eb..a8c9b1e 100644
---- a/test/lifecycle.test.js
++++ b/test/lifecycle.test.js
@@@ -15,15 +15,15 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
let post;
await check('the ten charter tables exist (package tables and views included)', async () => {
-- const names = new Set(t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
++ const names = new Set((await t.ctx.store.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name));
for (const n of CHARTER_TABLES) assert.ok(names.has(n), `missing ${n}`);
for (const n of ['blog_schedule_jobs', 'blog_terms', 'blog_term_links', 'blog_post_citations', 'blog_post_attachments', 'blog_index_revisions', 'event_outbox']) assert.ok(names.has(n), `missing ${n}`);
});
await check('the official blog exists at / and its owner comes from BLOG_OFFICIAL_OWNERS', async () => {
-- const blog = t.ctx.blogs.official();
++ const blog = await t.ctx.blogs.official();
assert.strictEqual(blog.handle, 'openvibe');
-- assert.strictEqual(t.ctx.blogs.membership(blog, t.official.subject).role, 'owner');
++ assert.strictEqual((await t.ctx.blogs.membership(blog, t.official.subject)).role, 'owner');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.match(r.text, /The OpenVibe blog/);
@@@ -47,8 +47,8 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual(post.state, 'draft');
assert.strictEqual(post.revision, 1);
assert.deepStrictEqual(post.tags, ['Astronomy', 'night sky']);
-- assert.strictEqual(t.events('blog.post.created').length, 1);
-- assert.strictEqual(t.events(/index_document/).length, 0);
++ assert.strictEqual((await t.events('blog.post.created')).length, 1);
++ assert.strictEqual((await t.events(/index_document/)).length, 0);
assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /First light/);
});
@@@ -62,7 +62,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual(stale.json().code, 'revision.conflict');
const missing = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { body: 'no base' } });
assert.strictEqual(missing.status, 428);
-- assert.throws(() => t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);
++ await assert.rejects(t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);
});
await check('publish: page served without JavaScript, BlogPosting from real fields, events + Search document', async () => {
@@@ -85,13 +85,13 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual(posting.datePublished, new Date(t.clock.now()).toISOString());
assert.strictEqual(posting.publisher, undefined, 'no publisher invented for a member blog');
assert.strictEqual(posting.aggregateRating, undefined);
-- assert.strictEqual(t.events('blog.post.published').length, 1);
-- const idx = t.events('blog.index_document.upserted');
++ assert.strictEqual((await t.events('blog.post.published')).length, 1);
++ const idx = await t.events('blog.index_document.upserted');
assert.strictEqual(idx.length, 1);
assert.strictEqual(idx[0].payload.visibility, 'public');
assert.strictEqual(idx[0].payload.canonical_url, 'https://openvibe.blog/@alice/first-light');
assert.ok(require('openvibe-contracts').validate('search.index-document@1', idx[0].payload).valid);
-- for (const e of t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);
++ for (const e of await t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);
});
await check('feeds (RSS, Atom, JSON Feed), sitemaps, tag/category/author pages list it', async () => {
@@@ -113,11 +113,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 2, title: 'First light (revised)' } });
assert.strictEqual(r.json().revision, 3);
assert.match((await t.get('/@alice/first-light')).text, /<h1>First light<\/h1>/);
-- assert.strictEqual(t.events('blog.post.updated').length, 0);
++ assert.strictEqual((await t.events('blog.post.updated')).length, 0);
await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });
assert.match((await t.get('/@alice/first-light')).text, /<h1>First light \(revised\)<\/h1>/);
-- assert.strictEqual(t.events('blog.post.updated').length, 1);
-- assert.strictEqual(t.events('blog.index_document.upserted').length, 2);
++ assert.strictEqual((await t.events('blog.post.updated')).length, 1);
++ assert.strictEqual((await t.events('blog.index_document.upserted')).length, 2);
});
await check('history: diff and revert as a new revision; revisions 1–3 are untouched', async () => {
@@@ -130,7 +130,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.deepStrictEqual(revs.map((x) => x.number), [4, 3, 2, 1]);
assert.strictEqual(revs[0].kind, 'revert');
assert.strictEqual(revs[0].revertedTo, 1);
-- assert.strictEqual(t.ctx.store.revisions.get(post.id, 4).content, t.ctx.store.revisions.get(post.id, 1).content);
++ assert.strictEqual((await t.ctx.store.revisions.get(post.id, 4)).content, (await t.ctx.store.revisions.get(post.id, 1)).content);
});
await check('unpublish: gone from pages, feeds, sitemap; Search gets a tombstone', async () => {
@@@ -139,10 +139,10 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /first-light/);
assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /first-light/);
-- const del = t.events('blog.index_document.deleted');
++ const del = await t.events('blog.index_document.deleted');
assert.strictEqual(del.length, 1);
assert.deepStrictEqual(Object.keys(del[0].payload).sort(), ['id', 'revision', 'type']);
-- assert.strictEqual(t.events('blog.post.unpublished').length, 1);
++ assert.strictEqual((await t.events('blog.post.unpublished')).length, 1);
});
await check('delete: 410 Gone for readers, blog.post.deleted, revisions kept', async () => {
@@@ -152,9 +152,9 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const page = await t.get('/@alice/first-light');
assert.strictEqual(page.status, 410);
assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
-- assert.strictEqual(t.events('blog.post.deleted').length, 1);
-- assert.strictEqual(t.ctx.store.revisions.list(post.id).length, 4);
-- const last = t.events(/index_document/).pop();
++ assert.strictEqual((await t.events('blog.post.deleted')).length, 1);
++ assert.strictEqual((await t.ctx.store.revisions.list(post.id)).length, 4);
++ const last = (await t.events(/index_document/)).pop();
assert.strictEqual(last.event_type, 'blog.index_document.deleted');
});
@@@ -169,14 +169,14 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const made = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'Cited', body: `Sources below.\n\n${LONG}`, citations: [{ url: 'https://example.org/a', title: 'A', quote: 'quoted' }] } });
assert.strictEqual(made.status, 201, made.text);
const id = made.json().post.id;
-- const citesAt = () => { const h = t.ctx.store.revisions.head(id); return t.ctx.store.citations.forRevision(id, h.number).map((c) => c.url); };
-- assert.deepStrictEqual(citesAt(), ['https://example.org/a']);
-- let r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: t.ctx.store.revisions.head(id).number, body: `Edited in /write.\n\n${LONG}` } });
++ const citesAt = async () => { const h = await t.ctx.store.revisions.head(id); return (await t.ctx.store.citations.forRevision(id, h.number)).map((c) => c.url); };
++ assert.deepStrictEqual(await citesAt(), ['https://example.org/a']);
++ let r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: (await t.ctx.store.revisions.head(id)).number, body: `Edited in /write.\n\n${LONG}` } });
assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(citesAt(), ['https://example.org/a'], 'carried forward');
-- r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: t.ctx.store.revisions.head(id).number, body: `Replaced.\n\n${LONG}`, citations: [] } });
++ assert.deepStrictEqual(await citesAt(), ['https://example.org/a'], 'carried forward');
++ r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: (await t.ctx.store.revisions.head(id)).number, body: `Replaced.\n\n${LONG}`, citations: [] } });
assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(citesAt(), [], 'an explicit empty list removes them');
++ assert.deepStrictEqual(await citesAt(), [], 'an explicit empty list removes them');
});
await t.close();
ddiff --git a/test/members-vip.test.js b/test/members-vip.test.js
iindex 4b03bab..e76522c 100644
---- a/test/members-vip.test.js
++++ b/test/members-vip.test.js
@@@ -146,11 +146,11 @@ function startVip() {
for (const p of ['/@carol/feed.xml', '/@carol/atom.xml', '/@carol/feed.json', '/feed.xml', '/sitemap.xml', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/@carol', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol/feed', '/llms.txt']) {
for (const as of [undefined, fan]) noBody((await t.get(p, { as })).text, p);
}
-- const all = t.events();
++ const all = await t.events();
assert.ok(all.length > 0);
for (const e of all) noBody(JSON.stringify(e), e.event_type);
-- assert.strictEqual(t.events('blog.index_document.upserted').filter((e) => e.payload.id === mem.id).length, 0, 'Search never got the members post');
-- const doc = t.ctx.publication.documentFor(t.ctx.blogs.byHandle('carol'), t.ctx.posts.get(mem.id), { forSearch: false }).doc;
++ assert.strictEqual((await t.events('blog.index_document.upserted')).filter((e) => e.payload.id === mem.id).length, 0, 'Search never got the members post');
++ const doc = (await t.ctx.publication.documentFor(await t.ctx.blogs.byHandle('carol'), await t.ctx.posts.get(mem.id), { forSearch: false })).doc;
assert.strictEqual(doc.body, '');
assert.strictEqual(doc.summary, 'What happened backstage this week.');
assert.deepStrictEqual(doc.acl.entitlements, ['vip:carol'], 'the default gate label names the blog');
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
iindex cda4a8e..4074934 100644
---- a/test/moderation-events.test.js
++++ b/test/moderation-events.test.js
@@@ -20,8 +20,8 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
assert.strictEqual((await t.get(`/api/v1/posts/${p.id}/publish`, { as: dana, json: {} })).status, 200);
return p;
};
-- const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()
-- .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');
++ const moderation = async () => await (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all())
++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');
const valid = (e) => {
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
const r = contracts.validate('blog.moderation.action@1', e.payload);
@@@ -37,14 +37,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
const b = await make('Own delete');
assert.strictEqual((await t.get(`/api/v1/posts/${a.id}/unpublish`, { as: dana, json: {} })).status, 200);
assert.strictEqual((await t.get(`/api/v1/posts/${b.id}`, { as: dana, method: 'DELETE' })).status < 300, true);
-- assert.strictEqual(moderation().length, 0);
++ assert.strictEqual((await moderation()).length, 0);
});
await check('staff unpublishing someone else\'s post: exactly one valid event', async () => {
const p = await make('Staff unpublish');
const r = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
assert.strictEqual(r.status, 200, r.text);
-- const ev = moderation();
++ const ev = await moderation();
assert.strictEqual(ev.length, 1);
valid(ev[0]);
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `post:${p.id}` });
@@@ -53,14 +53,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
assert.strictEqual(ev[0].payload.details.previous, 'published');
const again = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
assert.strictEqual(again.status, 200);
-- assert.strictEqual(moderation().length, 1, 'nothing changed, nothing reported');
++ assert.strictEqual((await moderation()).length, 1, 'nothing changed, nothing reported');
});
await check('staff deleting someone else\'s post: exactly one valid event', async () => {
const p = await make('Staff delete');
const r = await t.get(`/api/v1/posts/${p.id}`, { as: admin, method: 'DELETE' });
assert.ok(r.status < 300, r.text);
-- const ev = moderation();
++ const ev = await moderation();
assert.strictEqual(ev.length, 2);
valid(ev[1]);
assert.strictEqual(ev[1].payload.action, 'post.deleted');
ddiff --git a/test/nojs-editor.test.js b/test/nojs-editor.test.js
iindex 28efcf7..fef9693 100644
---- a/test/nojs-editor.test.js
++++ b/test/nojs-editor.test.js
@@@ -39,7 +39,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
await check('a form without the token is refused', async () => {
const r = await t.get('/write/@eve/new', { as: eve, form: { title: 'No token', body: 'x' } });
assert.strictEqual(r.status, 403);
-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n, 0);
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n, 0);
});
await check('new draft, edit and publish with forms', async () => {
@@@ -68,7 +68,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
const r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Stale', body: 'stale' } });
assert.strictEqual(r.status, 412);
assert.match(r.text, /Someone saved revision 2/);
-- assert.strictEqual(t.ctx.store.revisions.head(postId).fields.title, 'Notes without script');
++ assert.strictEqual((await t.ctx.store.revisions.head(postId)).fields.title, 'Notes without script');
});
await check('history, diff and preview are plain pages', async () => {
@@@ -92,7 +92,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
assert.deepStrictEqual(thread.ref, { service: 'blog', type: 'post', id: postId, label: 'Notes without script' });
const posted = t.community.calls.find((c) => c.method === 'POST' && /\/comments$/.test(c.url));
assert.strictEqual(posted.subject, eve.subject, 'commented as the member (X-OV-Subject)');
-- const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('blog_post_discussion_refs')").all().map((c) => c.name);
++ const cols = (await t.ctx.store.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'blog_post_discussion_refs'").all()).map((c) => c.name);
assert.deepStrictEqual(cols.sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'only the reference is stored');
});
@@@ -125,14 +125,14 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
await t.get('/write', { as: mallory }); // signs in once: now known by username
let r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve’s notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', itemCount: '10', fullContent: '1' } });
assert.strictEqual(r.status, 303, r.text);
-- const blog = t.ctx.blogs.byHandle('eve');
++ const blog = await t.ctx.blogs.byHandle('eve');
assert.strictEqual(blog.theme, 'paper');
-- assert.strictEqual(t.ctx.blogs.feedSettings(blog).json, 0);
++ assert.strictEqual((await t.ctx.blogs.feedSettings(blog)).json, 0);
assert.strictEqual((await t.get('/@eve/feed.json')).status, 404);
assert.match((await t.get('/@eve')).text, /data-blog-theme="paper"/);
r = await t.get('/write/@eve/members', { as: eve, form: { _csrf: csrf, member: 'mallory', role: 'author' } });
assert.strictEqual(r.status, 303, r.text);
-- assert.strictEqual(t.ctx.blogs.membership(blog, mallory.subject).role, 'author');
++ assert.strictEqual((await t.ctx.blogs.membership(blog, mallory.subject)).role, 'author');
const bad = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'x', theme: 'my-own-css-engine' } });
assert.strictEqual(bad.status, 422);
const notOwner = await t.get('/write/@eve/settings', { as: mallory });
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
iindex b72be0b..2b0c521 100644
---- a/test/privacy.test.js
++++ b/test/privacy.test.js
@@@ -92,9 +92,9 @@ function assertPrivate(r, what) {
});
await check('Search receives only the public post; the others were never sent', async () => {
-- const ids = t.events('blog.index_document.upserted').map((e) => e.payload.id);
++ const ids = (await t.events('blog.index_document.upserted')).map((e) => e.payload.id);
assert.deepStrictEqual(ids, [pub.id]);
-- const published = t.events('blog.post.published');
++ const published = await t.events('blog.post.published');
assert.strictEqual(published.length, 4);
for (const e of published) {
if (e.subject.id === pub.id) assert.strictEqual(e.visibility, 'public');
@@@ -106,12 +106,12 @@ function assertPrivate(r, what) {
await check('public → members: a Search tombstone, gone from feeds/sitemaps, anonymous 403, no public cache', async () => {
const r = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'members', entitlement_key: 'vip:carol' } });
assert.strictEqual(r.status, 200, r.text);
-- const del = t.events('blog.index_document.deleted');
++ const del = await t.events('blog.index_document.deleted');
assert.strictEqual(del.length, 1);
assert.strictEqual(del[0].payload.id, pub.id);
-- const up = t.events('blog.index_document.upserted').find((e) => e.payload.id === pub.id);
++ const up = (await t.events('blog.index_document.upserted')).find((e) => e.payload.id === pub.id);
assert.ok(del[0].payload.revision > up.payload.revision, 'the tombstone outranks the old document');
-- assert.strictEqual(t.events('blog.post.updated').length, 1);
++ assert.strictEqual((await t.events('blog.post.updated')).length, 1);
const a = await t.get('/@carol/open-post');
assert.strictEqual(a.status, 403);
assertPrivate(a, 'after privatising');
@@@ -121,16 +121,16 @@ function assertPrivate(r, what) {
await check('members → public again: re-indexed with a higher revision', async () => {
await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'public' } });
-- const ups = t.events('blog.index_document.upserted').filter((e) => e.payload.id === pub.id);
-- const del = t.events('blog.index_document.deleted')[0];
++ const ups = (await t.events('blog.index_document.upserted')).filter((e) => e.payload.id === pub.id);
++ const del = (await t.events('blog.index_document.deleted'))[0];
assert.strictEqual(ups.length, 2);
assert.ok(ups[1].payload.revision > del.payload.revision);
});
await check('without an entitlement service the check fails closed', async () => {
const { createEntitlementChecker } = require('../server/domain/access');
-- const blog = t.ctx.blogs.byHandle('carol');
-- const post = t.ctx.posts.get(mem.id);
++ const blog = await t.ctx.blogs.byHandle('carol');
++ const post = await t.ctx.posts.get(mem.id);
const args = { subject: vip.subject, blog, post };
const none = createEntitlementChecker({ provider: 'none' });
assert.strictEqual(await none.has(args), false);
ddiff --git a/test/schedule.test.js b/test/schedule.test.js
iindex 27bcd69..5f7884d 100644
---- a/test/schedule.test.js
++++ b/test/schedule.test.js
@@@ -27,7 +27,7 @@ const MIN = 60 * 1000;
const b = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });
assert.strictEqual(b.json().created, false);
assert.strictEqual(b.json().job.id, a.json().job.id);
-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id).n, 1, 'charter view blog_schedules');
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id)).n, 1, 'charter view blog_schedules');
assert.strictEqual(b.json().post.state, 'scheduled');
assert.strictEqual((await t.get('/@ann/scheduled-one')).status, 404, 'not public before its time');
assert.doesNotMatch((await t.get('/@ann/feed.xml')).text, /Scheduled one/);
@@@ -41,11 +41,11 @@ const MIN = 60 * 1000;
await check('a worker dies after publishing and before completing; after a restart the job re-runs once, harmlessly', async () => {
t.clock.advance(11 * MIN);
// Worker A: claim the job and apply its effect, then "crash" (no complete()).
-- const [job] = t.ctx.store.scheduler.claim({ worker: 'worker-A' });
++ const [job] = await t.ctx.store.scheduler.claim({ worker: 'worker-A' });
assert.ok(job, 'claimed');
-- t.ctx.posts.applyPublish(job.entityId, job.revision, null);
-- assert.strictEqual(t.events('blog.post.published').length, 1);
-- const idxBefore = t.events('blog.index_document.upserted').length;
++ await t.ctx.posts.applyPublish(job.entityId, job.revision, null);
++ assert.strictEqual((await t.events('blog.post.published')).length, 1);
++ const idxBefore = (await t.events('blog.index_document.upserted')).length;
assert.strictEqual(idxBefore, 1);
await t.restart(); // same database file, new process state
@@@ -56,8 +56,8 @@ const MIN = 60 * 1000;
s = await t.ctx.worker.scheduleTick();
assert.strictEqual(s.done.length, 1, 'the job re-ran after the lease expired');
assert.strictEqual(s.done[0].result, false, 'the effect changed nothing the second time');
-- assert.strictEqual(t.events('blog.post.published').length, 1, 'exactly one published event');
-- assert.strictEqual(t.events('blog.index_document.upserted').length, 1, 'exactly one Search upsert');
++ assert.strictEqual((await t.events('blog.post.published')).length, 1, 'exactly one published event');
++ assert.strictEqual((await t.events('blog.index_document.upserted')).length, 1, 'exactly one Search upsert');
const page = await t.get('/@ann/scheduled-one');
assert.strictEqual(page.status, 200);
s = await t.ctx.worker.scheduleTick();
@@@ -70,7 +70,7 @@ const MIN = 60 * 1000;
t.clock.advance(11 * MIN);
const s = await t.ctx.worker.scheduleTick();
assert.strictEqual(s.done.length, 1);
-- assert.strictEqual(t.ctx.posts.get(p2.id).state, 'published');
++ assert.strictEqual((await t.ctx.posts.get(p2.id)).state, 'published');
assert.match((await t.get('/@ann/feed.xml')).text, /Scheduled two/);
});
@@@ -78,7 +78,7 @@ const MIN = 60 * 1000;
const p3 = await mk('Scheduled three');
await t.get(`/api/v1/posts/${p3.id}/schedule`, { as: ann, json: { at: at() } });
await t.get(`/api/v1/posts/${p3.id}/publish`, { as: ann, json: {} });
-- const jobs = t.ctx.store.scheduler.jobs(p3.id);
++ const jobs = await t.ctx.store.scheduler.jobs(p3.id);
assert.deepStrictEqual(jobs.map((j) => j.status), ['cancelled']);
});
@@@ -86,14 +86,14 @@ const MIN = 60 * 1000;
const p4 = await mk('Doomed');
await t.get(`/api/v1/posts/${p4.id}/schedule`, { as: ann, json: { at: at() } });
// The revision disappears from under it: simulate a purge (audited erasure) of the post's revisions.
-- t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });
++ await t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });
t.clock.advance(11 * MIN);
for (let i = 0; i < 4; i++) { await t.ctx.worker.scheduleTick(); t.clock.advance(5 * MIN); }
-- const failed = t.events('blog.schedule.failed');
-- assert.strictEqual(failed.length, 1, JSON.stringify(t.ctx.store.scheduler.jobs(p4.id)));
++ const failed = await t.events('blog.schedule.failed');
++ assert.strictEqual(failed.length, 1, JSON.stringify(await t.ctx.store.scheduler.jobs(p4.id)));
assert.strictEqual(failed[0].subject.id, p4.id);
assert.match(failed[0].payload.error, /revision/i);
-- assert.strictEqual(t.ctx.posts.get(p4.id).state, 'draft');
++ assert.strictEqual((await t.ctx.posts.get(p4.id)).state, 'draft');
const ready = (await t.get('/api/ready')).json();
assert.ok(ready.degraded.includes('scheduler'), 'readiness reports the failed job');
});
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex abc08e2..155a0ea 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -36,16 +36,16 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
await mk(bob, 'bob', 'Bob Post');
const db = t.ctx.store.db;
-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT IN ('event_outbox')").all().map((r) => r.name);
-- const snapshot = () => Object.fromEntries(tables.map((n) => [n, db.prepare(`SELECT * FROM "${n}"`).all()]));
-- const same = (before, label) => { const after = snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };
++ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name NOT IN ('event_outbox', 'ov_migrations')").all()).map((r) => r.name);
++ const snapshot = async () => Object.fromEntries(await Promise.all(tables.map(async (n) => [n, await db.prepare(`SELECT * FROM "${n}"`).all()])));
++ const same = async (before, label) => { const after = await snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };
const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
const api = (method, p, json, as = bob) => t.get(`/api/v1${p}`, { as, method, json: json || {}, headers: { 'x-forwarded-for': nextAddress() } });
// The editor's forms live under /write.
const form = (p, fields, as = bob) => t.get(`/write${p}`, { as, form: { _csrf: t.csrf(as), ...fields }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } });
await check('API: Bob cannot touch Ann\'s posts by id or her blog by handle', async () => {
-- const before = snapshot();
++ const before = await snapshot();
for (const p of [annPost, annPrivate, annDraft]) {
refused(await api('PATCH', `/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, visibility: 'public', expected_revision: 1 }), `PATCH ${p.title}`);
refused(await api('POST', `/posts/${p.id}/publish`, {}), `publish ${p.title}`);
@@@ -63,11 +63,11 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
refused(await api('PUT', '/blogs/ann/theme', { theme: 'dark' }), 'theme');
refused(await api('POST', '/blogs/ann/posts', { title: 'Bob in Ann', body: LONG }), 'post into Ann\'s blog');
refused(await api('POST', '/blogs/ann/posts/ai-draft', { prompt: 'x' }), 'AI draft into Ann\'s blog');
-- same(before, 'API');
++ await same(before, 'API');
});
await check('forms: Bob, with his own valid form token, cannot post to Ann\'s posts or blog', async () => {
-- const before = snapshot();
++ const before = await snapshot();
for (const p of [annPost, annPrivate, annDraft]) {
refused(await form(`/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, expectedRevision: '1', visibility: 'public' }), `edit form ${p.title}`);
for (const a of ['publish', 'unpublish', 'delete', 'revert', 'review', 'schedule', 'unschedule']) {
@@@ -79,7 +79,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
refused(await form('/@ann/members', { username: 'cat', role: 'editor' }), 'members form');
refused(await form('/@ann/members/remove', { subject: ann.subject }), 'remove-member form');
refused(await form('/@ann/ai-draft', { prompt: 'x' }), 'AI-draft form');
-- same(before, 'forms');
++ await same(before, 'forms');
});
await check('controls: Ann can do what Bob could not', async () => {
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 596d364..5733dc3 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -93,7 +93,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
});
await check('public events and Search documents carry none of it', async () => {
-- const text = JSON.stringify(t.events().filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));
++ const text = JSON.stringify((await t.events()).filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));
assert.ok(text.includes('Open Post') || text.includes('open-control-words'), 'the open post is there (control)');
const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
assert.deepStrictEqual(hits, []);
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex c3094ad..c53e7cb 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -66,7 +66,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
});
await check('the events outbox carries no sentinel', async () => {
-- const text = JSON.stringify(t.events());
++ const text = JSON.stringify(await t.events());
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
});
ddiff --git a/test/seed-ops.test.js b/test/seed-ops.test.js
iindex c890fe8..a9d5cb7 100644
---- a/test/seed-ops.test.js
++++ b/test/seed-ops.test.js
@@@ -21,26 +21,26 @@ const { seedOfficialPost, loadFacts, SLUG } = require('../server/seed');
});
await check('the seed creates a DRAFT on the official blog, with one citation per quote', async () => {
-- const out = seedOfficialPost(t.ctx, {});
++ const out = await seedOfficialPost(t.ctx, {});
assert.strictEqual(out.created, true);
assert.strictEqual(out.post.state, 'draft');
assert.strictEqual(out.post.author_subject, t.official.subject);
-- const rev = t.ctx.store.revisions.head(out.post.id);
++ const rev = await t.ctx.store.revisions.head(out.post.id);
for (const it of facts.items) assert.ok(rev.content.includes(`> ${it.quote}`), `quote verbatim: ${it.repo}`);
-- const cites = t.ctx.store.citations.forRevision(out.post.id, rev.number);
++ const cites = await t.ctx.store.citations.forRevision(out.post.id, rev.number);
assert.strictEqual(cites.length, facts.items.length);
assert.ok(cites.every((c) => c.retrievedAt === new Date(facts.collected_at).toISOString() && c.quote && c.quote.text));
assert.strictEqual(rev.meta.authorship.mode, 'imported');
assert.doesNotMatch(rev.content, /\bfree\b|\$0/i);
assert.strictEqual((await t.get(`/@openvibe/${SLUG}`)).status, 404, 'not public');
-- assert.strictEqual(t.events(/index_document/).length, 0);
-- assert.strictEqual(seedOfficialPost(t.ctx, {}).created, false, 'idempotent');
++ assert.strictEqual((await t.events(/index_document/)).length, 0);
++ assert.strictEqual((await seedOfficialPost(t.ctx, {})).created, false, 'idempotent');
});
await check('publishing the seed needs a named reviewer; with one it is published and cites its sources', async () => {
-- assert.throws(() => seedOfficialPost(t.ctx, { publish: true }), /reviewer/);
++ await assert.rejects(async () => await seedOfficialPost(t.ctx, { publish: true }), /reviewer/);
const reviewer = t.network.addUser('reviewer');
-- const out = seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });
++ const out = await seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });
assert.strictEqual(out.published, true);
assert.strictEqual(out.review.reviewer, reviewer.subject);
const page = await t.get(`/@openvibe/${SLUG}`);
ddiff --git a/test/slug-media.test.js b/test/slug-media.test.js
iindex 818dbef..29575e4 100644
---- a/test/slug-media.test.js
++++ b/test/slug-media.test.js
@@@ -37,8 +37,8 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
assert.strictEqual(jf.items[0].id, feedIdBefore);
assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@dan/new-name');
assert.match((await t.get('/@dan/feed.xml')).text, new RegExp(`<guid isPermaLink="false">${feedIdBefore}</guid>`));
-- assert.strictEqual(t.events('blog.post.updated').length, 1, 'a canonical change of a published post is an update');
-- const docs = t.events('blog.index_document.upserted');
++ assert.strictEqual((await t.events('blog.post.updated')).length, 1, 'a canonical change of a published post is an update');
++ const docs = await t.events('blog.index_document.upserted');
assert.strictEqual(docs[docs.length - 1].payload.canonical_url, 'https://openvibe.blog/@dan/new-name');
});
@@@ -46,7 +46,7 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'newest-name' } });
assert.strictEqual((await t.get('/@dan/old-name')).headers.get('location'), '/@dan/newest-name');
assert.strictEqual((await t.get('/@dan/new-name')).headers.get('location'), '/@dan/newest-name');
-- assert.deepStrictEqual(t.ctx.store.redirects.history(post.id).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);
++ assert.deepStrictEqual((await t.ctx.store.redirects.history(post.id)).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);
});
await check('a new post may take a freed slug; the redirect then yields to it', async () => {