openvibers/openvibe.blog Commits

WIP postgres: db, outbox, config, migration

OP OpenVibers committed 28 Sept 2026 · 6df97e6658 · 6 files, +725 −147


Changes

ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..d6a5d86
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,366 @@
++-- phase: expand
++-- OpenVibe.Blog on PostgreSQL (ADR-035, roadmap WS-X2): the charter tables and the changelog as they were on SQLite,
++-- typed for PostgreSQL (epoch milliseconds stay bigint, ISO text times stay text, 0/1 flags stay integer, so the API is
++-- unchanged), the openvibe-publishing stores, the three charter views, and the openvibe-sdk outbox. Identifier
++-- columns sort like SQLite (COLLATE "C"). Generated once on 2026-09-28; never edited after it runs.
++
++CREATE TABLE blogs (
++ id text COLLATE "C" PRIMARY KEY,
++ kind text NOT NULL CHECK (kind IN ('official','member')),
++ handle text COLLATE "C" NOT NULL UNIQUE,
++ owner_subject text COLLATE "C",
++ title text NOT NULL,
++ description text,
++ theme text NOT NULL DEFAULT 'vibe',
++ language text NOT NULL DEFAULT 'en',
++ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended')),
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE UNIQUE INDEX blogs_owner ON blogs (owner_subject) WHERE kind = 'member';
++
++CREATE TABLE blog_memberships (
++ blog_id text COLLATE "C" NOT NULL REFERENCES blogs(id),
++ subject text COLLATE "C" NOT NULL,
++ role text NOT NULL CHECK (role IN ('owner','editor','author')),
++ added_by text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (blog_id, subject)
++);
++CREATE INDEX blog_memberships_subject ON blog_memberships (subject);
++
++CREATE TABLE blog_series (
++ id text COLLATE "C" PRIMARY KEY,
++ blog_id text COLLATE "C" NOT NULL REFERENCES blogs(id),
++ slug text COLLATE "C" NOT NULL,
++ title text NOT NULL,
++ description text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ UNIQUE (blog_id, slug)
++);
++
++CREATE TABLE blog_posts (
++ id text COLLATE "C" PRIMARY KEY,
++ blog_id text COLLATE "C" NOT NULL REFERENCES blogs(id),
++ slug text COLLATE "C" NOT NULL,
++ state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
++ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','unlisted','members','private')),
++ entitlement_key text,
++ author_subject text COLLATE "C" NOT NULL,
++ series_id text COLLATE "C" REFERENCES blog_series(id),
++ series_position integer,
++ published_revision integer,
++ first_published_at bigint,
++ published_at bigint,
++ allow_comments integer NOT NULL DEFAULT 1,
++ noindex integer NOT NULL DEFAULT 0,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ deleted_at bigint,
++ CHECK (visibility <> 'members' OR entitlement_key IS NOT NULL)
++);
++CREATE UNIQUE INDEX blog_posts_slug ON blog_posts (blog_id, slug) WHERE state <> 'deleted';
++CREATE INDEX blog_posts_listing ON blog_posts (blog_id, state, visibility, published_at);
++CREATE INDEX blog_posts_published ON blog_posts (published_at DESC, id) WHERE state = 'published';
++CREATE INDEX blog_posts_author ON blog_posts (author_subject, state);
++CREATE INDEX blog_posts_series ON blog_posts (series_id, series_position);
++
++CREATE TABLE blog_feed_settings (
++ blog_id text COLLATE "C" PRIMARY KEY REFERENCES blogs(id),
++ rss integer NOT NULL DEFAULT 1,
++ atom integer NOT NULL DEFAULT 1,
++ json integer NOT NULL DEFAULT 1,
++ item_count integer NOT NULL DEFAULT 20 CHECK (item_count BETWEEN 1 AND 50),
++ full_content integer NOT NULL DEFAULT 1,
++ updated_at bigint NOT NULL
++);
++
++-- Display cache of Network names for subjects (from sign-in claims or identity.subject.resolve).
++CREATE TABLE subject_projections (
++ subject text COLLATE "C" PRIMARY KEY,
++ username text,
++ display_name text,
++ avatar_url text,
++ refreshed_at bigint NOT NULL
++);
++CREATE INDEX subject_projections_username ON subject_projections (username);
++
++-- The network changelog (server/changelog.js): deployed commits of every service, batched into Patch notes posts.
++CREATE TABLE changelog_state (
++ service text COLLATE "C" PRIMARY KEY,
++ release text NOT NULL,
++ head text,
++ checked_at bigint NOT NULL
++);
++CREATE TABLE changelog_entries (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ service text COLLATE "C" NOT NULL,
++ repo text NOT NULL,
++ sha text COLLATE "C" NOT NULL,
++ subject text NOT NULL,
++ committed_at text,
++ deployed_at text COLLATE "C" NOT NULL,
++ release text NOT NULL,
++ release_lines integer NOT NULL DEFAULT 0,
++ major integer NOT NULL DEFAULT 0,
++ post_id text,
++ author text,
++ UNIQUE (service, sha)
++);
++CREATE INDEX idx_changelog_pending ON changelog_entries (post_id, deployed_at);
++CREATE INDEX idx_changelog_service ON changelog_entries (service, deployed_at);
++CREATE INDEX idx_changelog_feed ON changelog_entries (deployed_at DESC, id DESC);
++CREATE TABLE changelog_posts (
++ post_id text COLLATE "C" 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 bigint NOT NULL
++);
++CREATE TABLE changelog_history (
++ service text COLLATE "C" PRIMARY KEY,
++ imported integer NOT NULL,
++ imported_at bigint NOT NULL
++);
++
++-- openvibe-publishing/revisions (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_revisions (
++ id text PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ number integer NOT NULL CHECK (number >= 1),
++ parent_id text,
++ parent_number integer,
++ kind text NOT NULL CHECK (kind IN ('edit','revert','import')),
++ reverted_to integer,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ content_hash text NOT NULL,
++ author text,
++ message text,
++ created_at bigint NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS blog_post_revisions_entity_num ON blog_post_revisions (entity_id, number);
++CREATE TABLE IF NOT EXISTS blog_post_drafts (
++ entity_id text COLLATE "C" NOT NULL,
++ owner text COLLATE "C" NOT NULL,
++ base_revision integer NOT NULL,
++ content text NOT NULL,
++ fields jsonb NOT NULL DEFAULT '{}',
++ meta jsonb NOT NULL DEFAULT '{}',
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (entity_id, owner)
++);
++CREATE INDEX IF NOT EXISTS blog_post_drafts_updated ON blog_post_drafts (entity_id, updated_at DESC, owner);
++CREATE TABLE IF NOT EXISTS blog_post_revision_purges (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++CREATE OR REPLACE FUNCTION blog_post_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'blog_post_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM blog_post_revision_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'blog_post_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER blog_post_revisions_no_update BEFORE UPDATE ON blog_post_revisions FOR EACH ROW EXECUTE FUNCTION blog_post_revisions_guard();
++CREATE OR REPLACE TRIGGER blog_post_revisions_no_delete BEFORE DELETE ON blog_post_revisions FOR EACH ROW EXECUTE FUNCTION blog_post_revisions_guard();
++
++-- openvibe-publishing/citations (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_citations (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL CHECK (revision >= 1),
++ anchor text,
++ source_item_id text COLLATE "C",
++ url text,
++ title text,
++ retrieved_at timestamptz,
++ quote_text text,
++ quote_start integer,
++ quote_end integer,
++ license_note text,
++ carried_from bigint REFERENCES blog_post_citations(id),
++ attached_by text,
++ attached_at bigint NOT NULL,
++ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
++);
++CREATE INDEX IF NOT EXISTS blog_post_citations_rev ON blog_post_citations (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS blog_post_citations_source ON blog_post_citations (source_item_id, entity_id, revision, id) WHERE source_item_id IS NOT NULL;
++CREATE INDEX IF NOT EXISTS blog_post_citations_carried ON blog_post_citations (carried_from) WHERE carried_from IS NOT NULL;
++CREATE TABLE IF NOT EXISTS blog_post_citation_purges (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ reason text NOT NULL,
++ purged_by text,
++ purged_at bigint NOT NULL
++);
++CREATE OR REPLACE FUNCTION blog_post_citations_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'blog_post_citations rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM blog_post_citation_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'blog_post_citations rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER blog_post_citations_no_update BEFORE UPDATE ON blog_post_citations FOR EACH ROW EXECUTE FUNCTION blog_post_citations_guard();
++CREATE OR REPLACE TRIGGER blog_post_citations_no_delete BEFORE DELETE ON blog_post_citations FOR EACH ROW EXECUTE FUNCTION blog_post_citations_guard();
++
++-- openvibe-publishing/taxonomy (prefix blog)
++CREATE TABLE IF NOT EXISTS blog_terms (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ vocabulary text NOT NULL,
++ slug text NOT NULL,
++ name text NOT NULL,
++ parent_id bigint REFERENCES blog_terms(id),
++ description text,
++ created_at bigint NOT NULL,
++ UNIQUE (vocabulary, slug)
++);
++CREATE INDEX IF NOT EXISTS blog_terms_parent ON blog_terms (parent_id, name);
++CREATE INDEX IF NOT EXISTS blog_terms_vocab ON blog_terms (vocabulary, name);
++CREATE TABLE IF NOT EXISTS blog_term_links (
++ entity_id text COLLATE "C" NOT NULL,
++ term_id bigint NOT NULL REFERENCES blog_terms(id),
++ position integer NOT NULL DEFAULT 0,
++ created_at bigint NOT NULL,
++ PRIMARY KEY (entity_id, term_id)
++);
++CREATE INDEX IF NOT EXISTS blog_term_links_term ON blog_term_links (term_id, entity_id);
++
++-- openvibe-publishing/schedule (prefix blog)
++CREATE TABLE IF NOT EXISTS blog_schedule_jobs (
++ id text COLLATE "C" PRIMARY KEY,
++ idem_key text NOT NULL UNIQUE,
++ entity_id text COLLATE "C" NOT NULL,
++ action text NOT NULL CHECK (action IN ('publish','unpublish')),
++ revision integer,
++ run_at bigint NOT NULL,
++ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),
++ attempts integer NOT NULL DEFAULT 0,
++ lease_owner text,
++ lease_until bigint,
++ last_error text,
++ result jsonb,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS blog_schedule_jobs_due ON blog_schedule_jobs (run_at, id) WHERE status = 'pending';
++CREATE INDEX IF NOT EXISTS blog_schedule_jobs_lease ON blog_schedule_jobs (lease_until) WHERE status = 'running';
++CREATE INDEX IF NOT EXISTS blog_schedule_jobs_entity ON blog_schedule_jobs (entity_id, run_at, id);
++
++-- openvibe-publishing/media (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_attachments (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer,
++ media_id text COLLATE "C" NOT NULL,
++ role text NOT NULL DEFAULT 'inline',
++ variant text,
++ alt text,
++ caption text,
++ position integer NOT NULL DEFAULT 0,
++ state text NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),
++ broken_reason text CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),
++ checked_at bigint,
++ created_at bigint NOT NULL,
++ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS blog_post_attachments_entity ON blog_post_attachments (entity_id, position, id);
++CREATE INDEX IF NOT EXISTS blog_post_attachments_media ON blog_post_attachments (media_id, entity_id);
++
++-- openvibe-publishing/discussion (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_discussion_refs (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ thread_id text NOT NULL,
++ ref jsonb NOT NULL,
++ resolved_at bigint NOT NULL
++);
++
++-- openvibe-publishing/authorship (prefix blog_post)
++CREATE TABLE IF NOT EXISTS blog_post_reviews (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ reviewer text NOT NULL,
++ decision text NOT NULL CHECK (decision IN ('approved','rejected')),
++ note text,
++ reviewed_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS blog_post_reviews_rev ON blog_post_reviews (entity_id, revision, id);
++CREATE INDEX IF NOT EXISTS blog_post_reviews_entity ON blog_post_reviews (entity_id, id);
++CREATE OR REPLACE FUNCTION blog_post_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'blog_post_reviews rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER blog_post_reviews_no_update BEFORE UPDATE ON blog_post_reviews FOR EACH ROW EXECUTE FUNCTION blog_post_reviews_guard();
++
++-- openvibe-publishing/seo (prefix blog)
++CREATE TABLE IF NOT EXISTS blog_redirects (
++ from_path text COLLATE "C" PRIMARY KEY,
++ entity_id text COLLATE "C" NOT NULL,
++ reason text,
++ created_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS blog_redirects_entity ON blog_redirects (entity_id, created_at, from_path);
++
++-- openvibe-publishing/index-hooks (prefix blog)
++CREATE TABLE IF NOT EXISTS blog_index_revisions (
++ owner text COLLATE "C" NOT NULL,
++ type text COLLATE "C" NOT NULL,
++ id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ hash text NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (owner, type, id)
++);
++
++
++-- Charter names over the package tables: the package tables are the only truth.
++CREATE VIEW blog_taxonomy AS
++ SELECT t.id, CASE WHEN t.vocabulary = 'tag' THEN 'tag' ELSE 'category' END AS kind,
++ b.id AS blog_id, t.vocabulary, t.slug, t.name, t.parent_id, t.description, t.created_at
++ FROM blog_terms t
++ LEFT JOIN blogs b ON t.vocabulary = 'category_' || lower(substr(b.id, 5));
++CREATE VIEW blog_post_terms AS
++ SELECT l.entity_id AS post_id, l.term_id, t.vocabulary, l.position, l.created_at
++ FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id;
++CREATE VIEW blog_schedules AS
++ SELECT id, idem_key, entity_id AS post_id, action, revision, run_at, status, attempts,
++ lease_owner, lease_until, last_error, result, created_at, updated_at
++ FROM blog_schedule_jobs;
++
++-- openvibe-sdk/events PostgreSQL outbox
++CREATE TABLE IF NOT EXISTS event_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
ddiff --git a/package-lock.json b/package-lock.json
iindex f0dc01a..e11a5e3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,18 +15,34 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
++ "pg": "^8.23.0"
},
"devDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
"jsonwebtoken": "^9.0.2"
},
"engines": {
"node": ">=22.0.0"
}
},
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.8",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -228,6 +244,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@@ -310,6 +335,15 @@
"node": ">=4.0.0"
}
},
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@@ -746,6 +780,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@@ -810,12 +887,24 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/lodash.defaults": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
++ "license": "MIT"
++ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@@ -1015,9 +1104,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.53.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
-- "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",
++ "version": "0.76.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1031,15 +1120,15 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "0.4.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",
++ "version": "1.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
++ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
-- "better-sqlite3": ">=11",
++ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
},
"peerDependenciesMeta": {
@@@ -1049,12 +1138,32 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.12.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
++ "version": "0.19.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",
++ "integrity": "sha512-5hNT9KVxtkLeGNYirqY1+n939lga/8XPbmrtqISQTNKaXNsS1AHhf4TBwWe+ZAamuCHUADJZu5Ov3OyZLN0nIA==",
"license": "MIT",
"engines": {
"node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "better-sqlite3": ">=11",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "better-sqlite3": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
}
},
"node_modules/openvibe-shared": {
@@@ -1081,6 +1190,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
++ "node_modules/pg": {
++ "version": "8.23.0",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.0",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.0",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.0"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.0",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.0",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@@ -1204,6 +1441,27 @@
"node": ">= 6"
}
},
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@@ -1419,6 +1677,21 @@
"simple-concat": "^1.0.0"
}
},
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@@ -1546,6 +1819,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 5148569..4844182 100644
---- a/package.json
++++ b/package.json
@@@ -2,15 +2,21 @@
"name": "openvibe-blog",
"version": "0.1.0",
"private": true,
-- "description": "OpenVibe.Blog — the official OpenVibe blog and a blog for every member: drafts, immutable revisions, series, tags and categories, scheduling, feeds, sitemaps and Community comments, server-rendered and useful without JavaScript.",
++ "description": "OpenVibe.Blog \u2014 the official OpenVibe blog and a blog for every member: drafts, immutable revisions, series, tags and categories, scheduling, feeds, sitemaps and Community comments, server-rendered and useful without JavaScript.",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
-- "seed": "node scripts/seed.js"
++ "seed": "node scripts/seed.js",
++ "test:pg": "BLOG_TEST_STORE=pg node test/run.js"
},
-- "keywords": ["openvibe", "blog", "publishing", "feeds"],
++ "keywords": [
++ "openvibe",
++ "blog",
++ "publishing",
++ "feeds"
++ ],
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Blog",
@@@ -21,13 +27,16 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
++ "pg": "^8.23.0"
},
"devDependencies": {
-- "jsonwebtoken": "^9.0.2"
++ "jsonwebtoken": "^9.0.2",
++ "@electric-sql/pglite": "^0.5.8"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/server/config.js b/server/config.js
iindex 215f209..0cf563d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -35,6 +35,10 @@ function load(env = process.env) {
hour: Math.max(1, int(env.BLOG_LIMITS_HOUR, 3000)),
},
++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:blog:' },
++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.BLOG_DB_PATH || './data/blog.db',
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
ddiff --git a/server/db.js b/server/db.js
iindex 38067e9..e4c45a6 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * Blog's own SQLite database: created on boot, idempotently. Nothing here is shared with another
++ * Blog's own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot. Nothing here is shared with another
* service; the publishing packages create their tables inside this database with Blog's prefixes.
*
* The ten charter tables (roadmap §15.13):
@@@ -22,12 +22,12 @@
*
* Also here: the package's companions (blog_post_drafts, blog_post_revision_purges,
* blog_post_citations, blog_post_attachments, blog_post_reviews, blog_post_discussion_refs,
-- * blog_index_revisions), the SDK's event_outbox, and subject_projections (a display cache of
++ * blog_index_revisions), the SDK's PostgreSQL event_outbox, the changelog tables, and subject_projections (a display cache of
* Network names, never authority).
*/
const fs = require('fs');
const path = require('path');
--const Database = require('better-sqlite3');
++const { createDb } = require('openvibe-sdk/db');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createTaxonomy } = require('openvibe-publishing/taxonomy');
@@@ -38,122 +38,35 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
const { createRedirectStore } = require('openvibe-publishing/seo');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
--const SCHEMA = `
--CREATE TABLE IF NOT EXISTS blogs (
-- id TEXT PRIMARY KEY, -- blg_<ULID>
-- kind TEXT NOT NULL CHECK (kind IN ('official','member')),
-- handle TEXT NOT NULL UNIQUE, -- /@handle; the official blog is 'openvibe' and served at /
-- owner_subject TEXT, -- usr_… (NULL for the official blog: its owners are memberships)
-- title TEXT NOT NULL,
-- description TEXT,
-- theme TEXT NOT NULL DEFAULT 'vibe', -- an openvibe-shared theme preset slug
-- language TEXT NOT NULL DEFAULT 'en',
-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended')),
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL
--);
--CREATE UNIQUE INDEX IF NOT EXISTS blogs_owner ON blogs (owner_subject) WHERE kind = 'member';
--
--CREATE TABLE IF NOT EXISTS blog_memberships (
-- blog_id TEXT NOT NULL REFERENCES blogs(id),
-- subject TEXT NOT NULL, -- usr_…
-- role TEXT NOT NULL CHECK (role IN ('owner','editor','author')),
-- added_by TEXT,
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- PRIMARY KEY (blog_id, subject)
--);
--CREATE INDEX IF NOT EXISTS blog_memberships_subject ON blog_memberships (subject);
--
--CREATE TABLE IF NOT EXISTS blog_series (
-- id TEXT PRIMARY KEY, -- ser_<ULID>
-- blog_id TEXT NOT NULL REFERENCES blogs(id),
-- slug TEXT NOT NULL,
-- title TEXT NOT NULL,
-- description TEXT,
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- UNIQUE (blog_id, slug)
--);
--
--CREATE TABLE IF NOT EXISTS blog_posts (
-- id TEXT PRIMARY KEY, -- pst_<ULID>
-- blog_id TEXT NOT NULL REFERENCES blogs(id),
-- slug TEXT NOT NULL,
-- state TEXT NOT NULL DEFAULT 'draft'
-- CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
-- visibility TEXT NOT NULL DEFAULT 'public'
-- CHECK (visibility IN ('public','unlisted','members','private')),
-- entitlement_key TEXT, -- members (VIP) posts: the entitlement that may read it
-- author_subject TEXT NOT NULL, -- usr_… accountable for the post
-- series_id TEXT REFERENCES blog_series(id),
-- series_position INTEGER,
-- published_revision INTEGER, -- which revision readers see (NULL: never published)
-- first_published_at INTEGER,
-- published_at INTEGER, -- the latest publication of published_revision
-- allow_comments INTEGER NOT NULL DEFAULT 1,
-- noindex INTEGER NOT NULL DEFAULT 0, -- the author asked search engines not to index it
-- created_at INTEGER NOT NULL,
-- updated_at INTEGER NOT NULL,
-- deleted_at INTEGER,
-- CHECK (visibility <> 'members' OR entitlement_key IS NOT NULL)
--);
--CREATE UNIQUE INDEX IF NOT EXISTS blog_posts_slug ON blog_posts (blog_id, slug) WHERE state <> 'deleted';
--CREATE INDEX IF NOT EXISTS blog_posts_listing ON blog_posts (blog_id, state, visibility, published_at);
--CREATE INDEX IF NOT EXISTS blog_posts_author ON blog_posts (author_subject, state);
--CREATE INDEX IF NOT EXISTS blog_posts_series ON blog_posts (series_id, series_position);
--
--CREATE TABLE IF NOT EXISTS blog_feed_settings (
-- blog_id TEXT PRIMARY KEY REFERENCES blogs(id),
-- rss INTEGER NOT NULL DEFAULT 1,
-- atom INTEGER NOT NULL DEFAULT 1,
-- json INTEGER NOT NULL DEFAULT 1,
-- item_count INTEGER NOT NULL DEFAULT 20 CHECK (item_count BETWEEN 1 AND 50),
-- full_content INTEGER NOT NULL DEFAULT 1,
-- updated_at INTEGER NOT NULL
--);
--
---- Display cache of Network names for subjects (from sign-in claims or identity.subject.resolve).
--CREATE TABLE IF NOT EXISTS subject_projections (
-- subject TEXT PRIMARY KEY,
-- username TEXT,
-- display_name TEXT,
-- avatar_url TEXT,
-- refreshed_at INTEGER NOT NULL
--);
--CREATE INDEX IF NOT EXISTS subject_projections_username ON subject_projections (username);
--`;
--
--// Charter names over the package tables (created after the stores, which create the tables).
--const VIEWS = `
--CREATE VIEW IF NOT EXISTS blog_taxonomy AS
-- SELECT t.id, CASE WHEN t.vocabulary = 'tag' THEN 'tag' ELSE 'category' END AS kind,
-- b.id AS blog_id, t.vocabulary, t.slug, t.name, t.parent_id, t.description, t.created_at
-- FROM blog_terms t
-- LEFT JOIN blogs b ON t.vocabulary = 'category_' || lower(substr(b.id, 5));
--CREATE VIEW IF NOT EXISTS blog_post_terms AS
-- SELECT l.entity_id AS post_id, l.term_id, t.vocabulary, l.position, l.created_at
-- FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id;
--CREATE VIEW IF NOT EXISTS blog_schedules AS
-- SELECT id, idem_key, entity_id AS post_id, action, revision, run_at, status, attempts,
-- lease_owner, lease_until, last_error, result, created_at, updated_at
-- FROM blog_schedule_jobs;
--`;
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
/**
-- * Open (or create) the database and every store on it.
-- * opts.now — injectable clock (epoch ms) shared by the stores, so tests and replays are deterministic.
++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite
++ * database in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
*/
--function openStore(dbPath, { now = () => Date.now(), leaseMs = 60000 } = {}) {
-- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
-- const db = new Database(dbPath);
-- db.pragma('journal_mode = WAL');
-- db.pragma('foreign_keys = ON');
-- db.pragma('busy_timeout = 5000');
-- db.exec(SCHEMA);
++async function openDb(config, { log = console, registry } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh blog)');
++ log.warn(`[Blog] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
++ fs.mkdirSync(DEV_PGLITE, { recursive: true });
++ const db = createDb({ pglite: DEV_PGLITE, service: 'blog', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'blog-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'blog', registry, log });
++}
++/**
++ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms) shared by the stores, so tests
++ * and replays are deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).
++ */
++function createStore(db, { now = () => Date.now(), leaseMs = 60000 } = {}) {
const revisions = createRevisionStore(db, { prefix: 'blog_post', now });
-- const store = {
++ return {
db,
now,
revisions,
@@@ -166,14 +79,17 @@ function openStore(dbPath, { now = () => Date.now(), leaseMs = 60000 } = {}) {
reviews: createReviewLog(db, { prefix: 'blog_post', now }),
redirects: createRedirectStore(db, { prefix: 'blog', now }),
sequencer: createIndexSequencer(db, { prefix: 'blog', now }),
-- tx: (fn) => db.transaction(fn)(),
++ tx: async (fn) => await db.tx(() => fn()),
close: () => db.close(),
};
-- db.exec(VIEWS);
-- return store;
++}
++
++/** openDb + createStore. */
++async function openStore(config, { now, leaseMs, log } = {}) {
++ return createStore(await openDb(config, { log }), { now, leaseMs });
}
const CHARTER_TABLES = ['blogs', 'blog_memberships', 'blog_posts', 'blog_post_revisions', 'blog_series',
'blog_taxonomy', 'blog_post_terms', 'blog_schedules', 'blog_redirects', 'blog_feed_settings'];
--module.exports = { openStore, CHARTER_TABLES };
++module.exports = { openDb, openStore, createStore, CHARTER_TABLES, MIGRATIONS };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 35afb93..c9b9762 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -23,7 +23,7 @@
*/
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
@@@ -39,7 +39,9 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
}
const events = createEventsClient(createClient(clientOpts), { source: 'blog' });
let lastError = null;
-- const outbox = createOutbox(db, {
++ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient
++ // transaction); several processes relay one table safely (leases).
++ const outbox = createPgOutbox(db, {
events,
intervalMs: config.events.intervalMs,
now,
@@@ -49,11 +51,10 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
lastError = msg;
},
});
-- outbox.ensureSchema();
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
-- function emit(envelope, { traceparent } = {}) {
-- return outbox.enqueue(envelope, { traceparent });
++ async function emit(envelope, { traceparent } = {}) {
++ return await outbox.enqueue(db, envelope, { traceparent });
}
/**
@@@ -78,8 +79,8 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
enabled,
start() { if (enabled) outbox.start(); },
stop: () => outbox.stop(),
-- kick() { if (enabled) outbox.kick(); },
-- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
++ async kick() { if (enabled) await outbox.kick(); },
++ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
};
}