openvibers/openvibe.blog Commits
WIP postgres: db, outbox, config, migration
Changes
nnew file mode 100644@@@ -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;@@@ -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"++ }}}}@@@ -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"@@@ -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.@@@ -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--);----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 };@@@ -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 }),};}
new file mode 100644
new file mode 100644
@@ -0,0 +1,366 @@
@@ -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;
@@ -15,18 +15,34 @@
@@ -15,18 +15,34 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.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": {
"devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -228,6 +244,15 @@
@@ -228,6 +244,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -310,6 +335,15 @@
@@ -310,6 +335,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -746,6 +780,49 @@
@@ -746,6 +780,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -810,12 +887,24 @@
@@ -810,12 +887,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1015,9 +1104,9 @@
@@ -1015,9 +1104,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1031,15 +1120,15 @@
@@ -1031,15 +1120,15 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"node_modules/openvibe-publishing": {
- "version": "0.4.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
},
},
"peerDependencies": {
"peerDependencies": {
- "better-sqlite3": ">=11",
+ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
"openvibe-shared": ">=1.5.0"
},
},
"peerDependenciesMeta": {
"peerDependenciesMeta": {
@@ -1049,12 +1138,32 @@
@@ -1049,12 +1138,32 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.12.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
+ "version": "0.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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
@@ -1081,6 +1190,134 @@
@@ -1081,6 +1190,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1204,6 +1441,27 @@
@@ -1204,6 +1441,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1419,6 +1677,21 @@
@@ -1419,6 +1677,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1546,6 +1819,15 @@
@@ -1546,6 +1819,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -2,15 +2,21 @@
@@ -2,15 +2,21 @@
"name": "openvibe-blog",
"name": "openvibe-blog",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"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",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test": "node test/run.js",
- "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",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Blog",
"repository": "github:OpenVibers/OpenVibe.Blog",
@@ -21,13 +27,16 @@
@@ -21,13 +27,16 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.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": {
"devDependencies": {
- "jsonwebtoken": "^9.0.2"
+ "jsonwebtoken": "^9.0.2",
+ "@electric-sql/pglite": "^0.5.8"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -35,6 +35,10 @@ function load(env = process.env) {
@@ -35,6 +35,10 @@ function load(env = process.env) {
hour: Math.max(1, int(env.BLOG_LIMITS_HOUR, 3000)),
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',
dbPath: env.BLOG_DB_PATH || './data/blog.db',
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'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.
* service; the publishing packages create their tables inside this database with Blog's prefixes.
*
*
* The ten charter tables (roadmap §15.13):
* The ten charter tables (roadmap §15.13):
@@ -22,12 +22,12 @@
@@ -22,12 +22,12 @@
*
*
* Also here: the package's companions (blog_post_drafts, blog_post_revision_purges,
* 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_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).
* Network names, never authority).
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createTaxonomy } = require('openvibe-publishing/taxonomy');
const { createTaxonomy } = require('openvibe-publishing/taxonomy');
@@ -38,122 +38,35 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
@@ -38,122 +38,35 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
const { createRedirectStore } = require('openvibe-publishing/seo');
const { createRedirectStore } = require('openvibe-publishing/seo');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
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
-);
-
-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 revisions = createRevisionStore(db, { prefix: 'blog_post', now });
- const store = {
+ return {
db,
db,
now,
now,
revisions,
revisions,
@@ -166,14 +79,17 @@ function openStore(dbPath, { now = () => Date.now(), leaseMs = 60000 } = {}) {
@@ -166,14 +79,17 @@ function openStore(dbPath, { now = () => Date.now(), leaseMs = 60000 } = {}) {
reviews: createReviewLog(db, { prefix: 'blog_post', now }),
reviews: createReviewLog(db, { prefix: 'blog_post', now }),
redirects: createRedirectStore(db, { prefix: 'blog', now }),
redirects: createRedirectStore(db, { prefix: 'blog', now }),
sequencer: createIndexSequencer(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(),
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',
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'];
'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 };
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
*/
*/
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
@@ -39,7 +39,9 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -39,7 +39,9 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
}
}
const events = createEventsClient(createClient(clientOpts), { source: 'blog' });
const events = createEventsClient(createClient(clientOpts), { source: 'blog' });
let lastError = null;
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,
events,
intervalMs: config.events.intervalMs,
intervalMs: config.events.intervalMs,
now,
now,
@@ -49,11 +51,10 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -49,11 +51,10 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
lastError = msg;
lastError = msg;
},
},
});
});
- outbox.ensureSchema();
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
/** 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 }) {
@@ -78,8 +79,8 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
enabled,
enabled,
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
stop: () => outbox.stop(),
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 }),
};
};
}
}