openvibers/openvibe.network Commits

Network on PostgreSQL, the foundation (plan T2): openvibe-sdk v0.25.1 with pg, iovalkey and PGlite; migrations 0001_core (69 tables generated from the booted SQLite schema: identities, text timestamps via ov_now(), case-insensitive usernames by a unique lower(username) index, triggers in PL/pgSQL, boot seeds left to the code) and 0002_analytics; DATABASE_URL/DATABASE_DIRECT_URL/PGLITE_DIR/VALKEY_URL config; scripts/migrate-to-postgres.js (import with per-table count and checksum, --pglite dry run) and scripts/pg-preflight-collisions.js; the per-process migrated test database runner (not wired into npm test until the code is converted). Additive: the service still runs on SQLite and npm test is unchanged.

OP OpenVibers committed 29 Sept 2026 · c114c40208 · 9 files, +1460 −7


Changes

ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 00000000..d93eb848
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,952 @@
++-- phase: expand
++-- OpenVibe.Network on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-29; never edited after it runs.
++-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).
++CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$
++BEGIN
++ IF t IS NULL THEN RETURN NULL; END IF;
++ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;
++ IF t ~ '\d\d:\d\d(:\d\d(\.\d+)?)?\s*(Z|[+-]\d\d(:?\d\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;
++ RETURN t::timestamp;
++EXCEPTION WHEN others THEN RETURN NULL;
++END $$;
++CREATE FUNCTION ov_now() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') $$;
++CREATE FUNCTION ov_now_iso() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
++CREATE FUNCTION ov_now_iso(modifier text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char((statement_timestamp() AT TIME ZONE 'UTC') + modifier::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
++CREATE FUNCTION datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$
++DECLARE ts timestamp := ov_ts(t);
++BEGIN
++ IF ts IS NULL THEN RETURN NULL; END IF;
++ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;
++ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');
++EXCEPTION WHEN others THEN RETURN NULL;
++END $$;
++CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;
++
++CREATE TABLE users (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ username text COLLATE "C" NOT NULL,
++ email text COLLATE "C" UNIQUE,
++ password_hash text COLLATE "C" NOT NULL,
++ display_name text COLLATE "C",
++ avatar_url text COLLATE "C",
++ bio text COLLATE "C" DEFAULT '',
++ role text COLLATE "C" DEFAULT 'user' CHECK(role IN ('user','streamer','global_mod','admin')),
++ profile_color text COLLATE "C" DEFAULT '#8b5cf6',
++ is_banned bigint DEFAULT 0,
++ ban_reason text COLLATE "C",
++ token_valid_after text COLLATE "C" DEFAULT NULL,
++ legacy_source text COLLATE "C",
++ legacy_id bigint,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ last_seen text COLLATE "C" DEFAULT ov_now(),
++ is_anon bigint DEFAULT 0,
++ anon_number bigint,
++ name_effect text COLLATE "C",
++ particle_effect text COLLATE "C",
++ email_verified bigint DEFAULT 0,
++ email_verified_at text COLLATE "C",
++ email_bounced_at text COLLATE "C",
++ email_bounce_reason text COLLATE "C",
++ history_paused bigint DEFAULT 0,
++ subject_id text COLLATE "C",
++ profile_revision bigint NOT NULL DEFAULT 0,
++ merged_into bigint,
++ deleted_at text COLLATE "C"
++);
++
++CREATE TABLE oauth_clients (
++ client_id text COLLATE "C" PRIMARY KEY,
++ client_secret text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ redirect_uris text COLLATE "C" NOT NULL,
++ is_first_party bigint DEFAULT 0,
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++
++CREATE TABLE oauth_codes (
++ code text COLLATE "C" PRIMARY KEY,
++ client_id text COLLATE "C" NOT NULL,
++ user_id bigint NOT NULL,
++ redirect_uri text COLLATE "C" NOT NULL,
++ scope text COLLATE "C" DEFAULT 'profile theme',
++ expires_at text COLLATE "C" NOT NULL,
++ used bigint DEFAULT 0,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ code_challenge text COLLATE "C",
++ code_challenge_method text COLLATE "C",
++ nonce text COLLATE "C",
++ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
++ FOREIGN KEY (user_id) REFERENCES users(id)
++);
++
++CREATE TABLE oauth_tokens (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ token text COLLATE "C" UNIQUE NOT NULL,
++ client_id text COLLATE "C" NOT NULL,
++ user_id bigint NOT NULL,
++ scope text COLLATE "C" DEFAULT 'profile theme',
++ expires_at text COLLATE "C" NOT NULL,
++ revoked bigint DEFAULT 0,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ family_id text COLLATE "C",
++ generation bigint NOT NULL DEFAULT 0,
++ revoked_reason text COLLATE "C",
++ revoked_at text COLLATE "C",
++ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
++ FOREIGN KEY (user_id) REFERENCES users(id)
++);
++
++CREATE TABLE user_preferences (
++ user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ theme_id text COLLATE "C" DEFAULT 'vibe',
++ custom_theme_variables text COLLATE "C",
++ language text COLLATE "C" DEFAULT 'en',
++ notifications_enabled bigint DEFAULT 1,
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ display_prefs text COLLATE "C",
++ FOREIGN KEY (user_id) REFERENCES users(id)
++);
++
++CREATE TABLE themes (
++ id text COLLATE "C" PRIMARY KEY,
++ name text COLLATE "C" NOT NULL,
++ slug text COLLATE "C" UNIQUE NOT NULL,
++ author_id bigint,
++ description text COLLATE "C" DEFAULT '',
++ mode text COLLATE "C" DEFAULT 'dark' CHECK(mode IN ('dark','light')),
++ variables text COLLATE "C" NOT NULL,
++ preview_colors text COLLATE "C",
++ is_builtin bigint DEFAULT 0,
++ is_public bigint DEFAULT 1,
++ downloads bigint DEFAULT 0,
++ rating_sum bigint DEFAULT 0,
++ rating_count bigint DEFAULT 0,
++ tags text COLLATE "C" DEFAULT '[]',
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (author_id) REFERENCES users(id)
++);
++
++CREATE TABLE linked_accounts (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ service_user_id text COLLATE "C" NOT NULL,
++ linked_at text COLLATE "C" DEFAULT ov_now(),
++ service_username text COLLATE "C",
++ last_used_at text COLLATE "C",
++ FOREIGN KEY (user_id) REFERENCES users(id),
++ UNIQUE(service, service_user_id)
++);
++
++CREATE TABLE audit_log (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint,
++ action text COLLATE "C" NOT NULL,
++ details text COLLATE "C",
++ ip text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++
++CREATE TABLE ip_log (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint,
++ ip text COLLATE "C" NOT NULL,
++ action text COLLATE "C" DEFAULT 'login',
++ country text COLLATE "C",
++ region text COLLATE "C",
++ city text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id)
++);
++
++CREATE TABLE site_settings (
++ key text COLLATE "C" PRIMARY KEY,
++ value text COLLATE "C",
++ type text COLLATE "C" DEFAULT 'string'
++);
++
++CREATE TABLE url_registry (
++ key text COLLATE "C" PRIMARY KEY,
++ label text COLLATE "C" NOT NULL,
++ category text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ scope text COLLATE "C" NOT NULL,
++ type text COLLATE "C" NOT NULL,
++ value text COLLATE "C",
++ description text COLLATE "C",
++ updated_by bigint,
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ source text COLLATE "C" NOT NULL DEFAULT 'admin'
++);
++
++CREATE TABLE notifications (
++ id text COLLATE "C" PRIMARY KEY,
++ user_id bigint NOT NULL,
++ type text COLLATE "C" NOT NULL,
++ category text COLLATE "C" NOT NULL,
++ priority text COLLATE "C" NOT NULL DEFAULT 'normal',
++ title text COLLATE "C" NOT NULL,
++ message text COLLATE "C",
++ icon text COLLATE "C",
++ sender_id bigint,
++ sender_name text COLLATE "C",
++ sender_avatar text COLLATE "C",
++ service text COLLATE "C",
++ url text COLLATE "C",
++ rich_content text COLLATE "C",
++ is_read bigint DEFAULT 0,
++ is_dismissed bigint DEFAULT 0,
++ is_emailed bigint DEFAULT 0,
++ expires_at text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE notification_preferences (
++ user_id bigint NOT NULL,
++ category text COLLATE "C" NOT NULL,
++ enabled bigint DEFAULT 1,
++ sound bigint DEFAULT 1,
++ toasts bigint DEFAULT 1,
++ email bigint DEFAULT 0,
++ PRIMARY KEY (user_id, category),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE email_delivery_log (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ email_type text COLLATE "C" NOT NULL,
++ recipient text COLLATE "C" NOT NULL,
++ subject text COLLATE "C",
++ status text COLLATE "C" NOT NULL CHECK(status IN ('sent', 'failed')),
++ error_message text COLLATE "C",
++ user_id bigint,
++ notification_id text COLLATE "C",
++ metadata text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
++ FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE SET NULL
++);
++
++CREATE TABLE password_reset_tokens (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ token_hash text COLLATE "C" UNIQUE NOT NULL,
++ expires_at text COLLATE "C" NOT NULL,
++ used_at text COLLATE "C",
++ requested_ip text COLLATE "C",
++ requested_user_agent text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE anon_users (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ anon_number bigint UNIQUE NOT NULL,
++ fingerprint text COLLATE "C",
++ session_token text COLLATE "C" UNIQUE NOT NULL,
++ display_name text COLLATE "C",
++ preferences text COLLATE "C" DEFAULT '{}',
++ total_messages bigint DEFAULT 0,
++ total_commands bigint DEFAULT 0,
++ first_seen text COLLATE "C" DEFAULT ov_now(),
++ last_seen text COLLATE "C" DEFAULT ov_now(),
++ ip text COLLATE "C",
++ subject_id text COLLATE "C"
++);
++
++CREATE TABLE user_sessions (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ session_token text COLLATE "C" UNIQUE NOT NULL,
++ device_name text COLLATE "C",
++ ip text COLLATE "C",
++ user_agent text COLLATE "C",
++ is_active bigint DEFAULT 1,
++ last_used text COLLATE "C" DEFAULT ov_now(),
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ expires_at text COLLATE "C" NOT NULL,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE user_effects (
++ user_id bigint NOT NULL,
++ effect_type text COLLATE "C" NOT NULL,
++ effect_id text COLLATE "C" NOT NULL,
++ is_active bigint DEFAULT 0,
++ acquired_at text COLLATE "C" DEFAULT ov_now(),
++ PRIMARY KEY (user_id, effect_type, effect_id),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE follows (
++ follower_id bigint NOT NULL,
++ followed_id bigint NOT NULL,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ PRIMARY KEY (follower_id, followed_id),
++ FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (followed_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE wallets (
++ user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ balance bigint NOT NULL DEFAULT 0,
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE coin_transactions (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ app_id text COLLATE "C",
++ delta bigint NOT NULL,
++ reason text COLLATE "C",
++ ref text COLLATE "C",
++ idempotency_key text COLLATE "C" UNIQUE,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE verification_keys (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ key text COLLATE "C" UNIQUE NOT NULL,
++ target_username text COLLATE "C" NOT NULL,
++ note text COLLATE "C" DEFAULT '',
++ created_by bigint NOT NULL,
++ used_by bigint,
++ status text COLLATE "C" DEFAULT 'active' CHECK(status IN ('active', 'used', 'revoked')),
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ used_at text COLLATE "C",
++ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
++);
++
++CREATE TABLE anon_ip_log (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ anon_id bigint NOT NULL,
++ ip text COLLATE "C" NOT NULL,
++ first_seen text COLLATE "C" DEFAULT ov_now(),
++ last_seen text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (anon_id) REFERENCES anon_users(id) ON DELETE CASCADE,
++ UNIQUE(anon_id, ip)
++);
++
++CREATE TABLE push_subscriptions (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ endpoint text COLLATE "C" NOT NULL UNIQUE,
++ keys_p256dh text COLLATE "C" NOT NULL,
++ keys_auth text COLLATE "C" NOT NULL,
++ user_agent text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE email_verification_tokens (
++ token_hash text COLLATE "C" PRIMARY KEY,
++ user_id bigint NOT NULL,
++ email text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ expires_at text COLLATE "C" NOT NULL,
++ used_at text COLLATE "C",
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++
++CREATE TABLE identity_legacy_map (
++ source_system text COLLATE "C" NOT NULL,
++ source_type text COLLATE "C" NOT NULL,
++ source_id text COLLATE "C" NOT NULL,
++ subject_id text COLLATE "C" NOT NULL,
++ first_seen_at text COLLATE "C" DEFAULT ov_now(),
++ verified_at text COLLATE "C",
++ metadata text COLLATE "C",
++ PRIMARY KEY (source_system, source_type, source_id)
++);
++
++CREATE TABLE principal_grants (
++ client_id text COLLATE "C" NOT NULL,
++ capability text COLLATE "C" NOT NULL,
++ audience text COLLATE "C" NOT NULL,
++ namespaces text COLLATE "C" NOT NULL DEFAULT '[]',
++ granted_at text COLLATE "C" DEFAULT ov_now(),
++ granted_by text COLLATE "C",
++ revoked_at text COLLATE "C",
++ expires_at text COLLATE "C",
++ reason text COLLATE "C",
++ revoked_by text COLLATE "C",
++ PRIMARY KEY (client_id, capability, audience)
++);
++
++CREATE TABLE principal_usage (
++ principal text COLLATE "C" NOT NULL,
++ route text COLLATE "C" NOT NULL,
++ capability text COLLATE "C" NOT NULL,
++ auth text COLLATE "C" NOT NULL,
++ allowed bigint NOT NULL,
++ code text COLLATE "C" NOT NULL DEFAULT '',
++ count bigint NOT NULL DEFAULT 0,
++ first_at text COLLATE "C" DEFAULT ov_now(),
++ last_at text COLLATE "C" DEFAULT ov_now(),
++ PRIMARY KEY (principal, route, auth, allowed, code)
++);
++
++CREATE TABLE principal_grant_changes (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ client_id text COLLATE "C" NOT NULL,
++ capability text COLLATE "C" NOT NULL,
++ audience text COLLATE "C" NOT NULL,
++ change text COLLATE "C" NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),
++ namespaces text COLLATE "C" NOT NULL DEFAULT '[]',
++ expires_at text COLLATE "C",
++ reason text COLLATE "C",
++ actor text COLLATE "C",
++ event_id text COLLATE "C",
++ at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++
++CREATE TABLE user_modules (
++ subject_id text COLLATE "C" NOT NULL,
++ namespace text COLLATE "C" NOT NULL,
++ version bigint NOT NULL,
++ revision bigint NOT NULL DEFAULT 0,
++ data text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ updated_by text COLLATE "C",
++ PRIMARY KEY (subject_id, namespace)
++);
++
++CREATE TABLE user_module_revisions (
++ subject_id text COLLATE "C" NOT NULL,
++ namespace text COLLATE "C" NOT NULL,
++ revision bigint NOT NULL,
++ PRIMARY KEY (subject_id, namespace)
++);
++
++CREATE TABLE user_module_retirements (
++ namespace text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ retired_seen_at bigint NOT NULL
++);
++
++
++CREATE TABLE dev_projects (
++ id text COLLATE "C" PRIMARY KEY,
++ owner_subject text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ environment_policy text COLLATE "C" NOT NULL DEFAULT 'sandbox' CHECK (environment_policy IN ('sandbox', 'sandbox+production')),
++ allowance text COLLATE "C" NOT NULL DEFAULT '[]',
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ archived_at text COLLATE "C",
++ archived_by text COLLATE "C"
++);
++
++CREATE TABLE dev_project_members (
++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
++ subject_id text COLLATE "C" NOT NULL,
++ role text COLLATE "C" NOT NULL CHECK (role IN ('owner', 'admin', 'developer', 'viewer')),
++ added_at text COLLATE "C" NOT NULL,
++ added_by text COLLATE "C" NOT NULL,
++ PRIMARY KEY (project_id, subject_id)
++);
++
++CREATE TABLE dev_apps (
++ id text COLLATE "C" PRIMARY KEY,
++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
++ name text COLLATE "C" NOT NULL,
++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),
++ oauth_client_id text COLLATE "C" NOT NULL UNIQUE,
++ client_type text COLLATE "C" NOT NULL CHECK (client_type IN ('confidential', 'public')),
++ redirect_uris text COLLATE "C" NOT NULL DEFAULT '[]',
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C"
++);
++
++CREATE TABLE dev_credentials (
++ id text COLLATE "C" PRIMARY KEY,
++ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),
++ secret_hash text COLLATE "C" NOT NULL UNIQUE,
++ hint text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C",
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C",
++ last_used_at text COLLATE "C"
++);
++
++CREATE TABLE dev_grants (
++ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),
++ capability text COLLATE "C" NOT NULL,
++ audience text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL CHECK (status IN ('requested', 'approved', 'denied', 'revoked')),
++ requested_by text COLLATE "C" NOT NULL,
++ requested_at text COLLATE "C" NOT NULL,
++ decided_by text COLLATE "C",
++ decided_at text COLLATE "C",
++ PRIMARY KEY (app_id, capability)
++);
++
++CREATE TABLE dev_quotas (
++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
++ capability text COLLATE "C" NOT NULL,
++ limit_value bigint NOT NULL CHECK (limit_value >= 0),
++ quota_window text COLLATE "C" NOT NULL CHECK (quota_window IN ('minute', 'hour', 'day', 'month', 'total')),
++ unit text COLLATE "C" NOT NULL DEFAULT 'requests',
++ updated_at text COLLATE "C" NOT NULL,
++ updated_by text COLLATE "C" NOT NULL,
++ PRIMARY KEY (project_id, capability)
++);
++
++CREATE TABLE dev_auth_codes (
++ code_hash text COLLATE "C" PRIMARY KEY,
++ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),
++ user_subject text COLLATE "C" NOT NULL,
++ redirect_uri text COLLATE "C" NOT NULL,
++ scope text COLLATE "C" NOT NULL DEFAULT '',
++ code_challenge text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C" NOT NULL,
++ used bigint NOT NULL DEFAULT 0
++);
++
++CREATE TABLE dev_audit (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ at text COLLATE "C" NOT NULL,
++ project_id text COLLATE "C",
++ actor text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ target text COLLATE "C",
++ detail text COLLATE "C" NOT NULL DEFAULT '{}',
++ request_id text COLLATE "C",
++ event_type text COLLATE "C",
++ event text COLLATE "C"
++);
++
++CREATE TABLE dev_usage_windows (
++ project_id text COLLATE "C" NOT NULL,
++ env text COLLATE "C" NOT NULL CHECK (env IN ('sandbox', 'production')),
++ service text COLLATE "C" NOT NULL,
++ capability text COLLATE "C" NOT NULL,
++ dimension text COLLATE "C" NOT NULL DEFAULT '',
++ unit text COLLATE "C" NOT NULL,
++ window_start text COLLATE "C" NOT NULL,
++ window_end text COLLATE "C" NOT NULL,
++ quantity bigint NOT NULL,
++ errors bigint NOT NULL,
++ error_codes text COLLATE "C" NOT NULL DEFAULT '{}',
++ revision bigint NOT NULL DEFAULT 1,
++ event_id text COLLATE "C" NOT NULL,
++ recorded_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (project_id, env, service, capability, dimension, unit, window_start)
++);
++
++CREATE TABLE dev_usage_daily (
++ project_id text COLLATE "C" NOT NULL,
++ env text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ capability text COLLATE "C" NOT NULL,
++ dimension text COLLATE "C" NOT NULL DEFAULT '',
++ unit text COLLATE "C" NOT NULL,
++ day text COLLATE "C" NOT NULL,
++ quantity bigint NOT NULL DEFAULT 0,
++ errors bigint NOT NULL DEFAULT 0,
++ error_codes text COLLATE "C" NOT NULL DEFAULT '{}',
++ updated_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (project_id, env, service, capability, dimension, unit, day)
++);
++
++CREATE TABLE dev_usage_errors (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ project_id text COLLATE "C" NOT NULL,
++ env text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ capability text COLLATE "C" NOT NULL,
++ at text COLLATE "C" NOT NULL,
++ code text COLLATE "C" NOT NULL,
++ status bigint,
++ trace_id text COLLATE "C",
++ ref text COLLATE "C" NOT NULL DEFAULT '',
++ event_id text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE user_blocks (
++ blocker_subject text COLLATE "C" NOT NULL,
++ blocked_subject text COLLATE "C" NOT NULL,
++ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
++ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ PRIMARY KEY (blocker_subject, blocked_subject),
++ CHECK (blocker_subject <> blocked_subject)
++);
++
++CREATE TABLE user_follows (
++ follower_subject text COLLATE "C" NOT NULL,
++ target_type text COLLATE "C" NOT NULL CHECK (target_type IN ('channel')),
++ target_id text COLLATE "C" NOT NULL,
++ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
++ notify_email bigint NOT NULL DEFAULT 1 CHECK (notify_email IN (0, 1)),
++ notify_push bigint NOT NULL DEFAULT 1 CHECK (notify_push IN (0, 1)),
++ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),
++ source text COLLATE "C" NOT NULL DEFAULT 'network',
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ PRIMARY KEY (follower_subject, target_type, target_id),
++ CHECK (follower_subject <> target_id)
++);
++
++CREATE TABLE follow_import_holds (
++ source text COLLATE "C" NOT NULL,
++ follower_ref text COLLATE "C" NOT NULL,
++ target_ref text COLLATE "C" NOT NULL,
++ reason text COLLATE "C" NOT NULL,
++ seen_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (source, follower_ref, target_ref)
++);
++
++CREATE TABLE status_incidents (
++ id text COLLATE "C" PRIMARY KEY,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('incident', 'maintenance')),
++ title text COLLATE "C" NOT NULL,
++ severity text COLLATE "C" CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),
++ state text COLLATE "C" NOT NULL,
++ services text COLLATE "C" NOT NULL DEFAULT '[]',
++ starts_at text COLLATE "C" NOT NULL,
++ ends_at text COLLATE "C",
++ created_by text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE status_incident_updates (
++ incident_id text COLLATE "C" NOT NULL REFERENCES status_incidents(id),
++ at text COLLATE "C" NOT NULL,
++ state text COLLATE "C" NOT NULL,
++ message text COLLATE "C" NOT NULL,
++ author text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE creator_streams (
++ stream_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ creator_subject text COLLATE "C" NOT NULL,
++ title text COLLATE "C",
++ category text COLLATE "C",
++ started_at text COLLATE "C" NOT NULL,
++ ended_at text COLLATE "C" NOT NULL,
++ duration_seconds bigint NOT NULL DEFAULT 0,
++ peak_viewers bigint,
++ avg_viewers double precision,
++ unique_chatters bigint,
++ messages bigint,
++ watch_minutes bigint,
++ received_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE platform_nodes (
++ id text COLLATE "C" PRIMARY KEY,
++ source text COLLATE "C" NOT NULL,
++ doc text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL,
++ reported_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE username_history (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ old_username text COLLATE "C" NOT NULL,
++ new_username text COLLATE "C" NOT NULL,
++ changed_by bigint,
++ changed_at text COLLATE "C" DEFAULT ov_now()
++);
++
++CREATE TABLE user_profile_changes (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ changed text COLLATE "C" NOT NULL,
++ at bigint NOT NULL
++);
++
++CREATE TABLE subject_aliases (
++ alias_id text COLLATE "C" PRIMARY KEY,
++ subject_id text COLLATE "C" NOT NULL,
++ merge_id text COLLATE "C" NOT NULL,
++ merged_at text COLLATE "C" NOT NULL,
++ merged_by text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE account_merges (
++ id text COLLATE "C" PRIMARY KEY,
++ from_subject text COLLATE "C" NOT NULL UNIQUE,
++ into_subject text COLLATE "C" NOT NULL,
++ from_user_id bigint NOT NULL,
++ into_user_id bigint NOT NULL,
++ initiated_by text COLLATE "C" NOT NULL CHECK (initiated_by IN ('person', 'staff')),
++ actor_subject text COLLATE "C" NOT NULL,
++ reason text COLLATE "C",
++ moved text COLLATE "C" NOT NULL,
++ pre_state text COLLATE "C",
++ merged_at text COLLATE "C" NOT NULL,
++ split_until text COLLATE "C" NOT NULL,
++ reduced_at text COLLATE "C"
++);
++
++CREATE TABLE account_merge_intents (
++ id text COLLATE "C" PRIMARY KEY,
++ into_user_id bigint NOT NULL,
++ created_at bigint NOT NULL,
++ expires_at bigint NOT NULL,
++ used_at bigint
++);
++
++CREATE TABLE account_exports (
++ id text COLLATE "C" PRIMARY KEY,
++ user_id bigint NOT NULL,
++ subject text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),
++ expected text COLLATE "C" NOT NULL DEFAULT '[]',
++ requested_at text COLLATE "C" NOT NULL,
++ deadline text COLLATE "C" NOT NULL,
++ ready_at text COLLATE "C",
++ expires_at text COLLATE "C",
++ size_bytes bigint,
++ services text COLLATE "C",
++ error text COLLATE "C"
++);
++
++CREATE TABLE account_export_parts (
++ export_id text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ received_at text COLLATE "C" NOT NULL,
++ files bigint NOT NULL,
++ bytes bigint NOT NULL,
++ body text COLLATE "C",
++ PRIMARY KEY (export_id, service)
++);
++
++CREATE TABLE account_deletions (
++ id text COLLATE "C" PRIMARY KEY,
++ user_id bigint NOT NULL,
++ subject text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),
++ requested_at text COLLATE "C" NOT NULL,
++ delete_after text COLLATE "C" NOT NULL,
++ cancelled_at text COLLATE "C",
++ cancelled_by text COLLATE "C",
++ deleted_at text COLLATE "C",
++ expected text COLLATE "C",
++ erased text COLLATE "C"
++);
++
++CREATE TABLE account_deletion_confirmations (
++ deletion_id text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ received_at text COLLATE "C" NOT NULL,
++ completed_at text COLLATE "C" NOT NULL,
++ erased text COLLATE "C" NOT NULL,
++ retained text COLLATE "C" NOT NULL,
++ PRIMARY KEY (deletion_id, service)
++);
++
++CREATE TABLE mod_principals (
++ mod_id text COLLATE "C" PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ runtime text COLLATE "C" NOT NULL,
++ name text COLLATE "C",
++ version text COLLATE "C",
++ publisher text COLLATE "C",
++ manifest text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
++ revision bigint NOT NULL DEFAULT 1,
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C",
++ updated_at text COLLATE "C" NOT NULL,
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C"
++);
++
++CREATE TABLE mod_principal_grants (
++ mod_id text COLLATE "C" NOT NULL,
++ capability text COLLATE "C" NOT NULL,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),
++ changed_at text COLLATE "C" NOT NULL,
++ changed_by text COLLATE "C",
++ PRIMARY KEY (mod_id, capability)
++);
++
++CREATE TABLE stream_live_announcements (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ streamer_key text COLLATE "C" NOT NULL,
++ stream_id text COLLATE "C",
++ sent_at text COLLATE "C" DEFAULT ov_now()
++);
++
++CREATE TABLE operator_alerts (
++ source text COLLATE "C" NOT NULL,
++ fingerprint text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ severity text COLLATE "C" NOT NULL,
++ summary text COLLATE "C" NOT NULL,
++ description text COLLATE "C",
++ service text COLLATE "C",
++ state text COLLATE "C" NOT NULL CHECK (state IN ('firing', 'resolved')),
++ started_at text COLLATE "C" NOT NULL,
++ opened_at bigint NOT NULL,
++ last_seen_at bigint NOT NULL,
++ notified_at bigint,
++ resolved_at bigint,
++ notices text COLLATE "C",
++ reopened bigint NOT NULL DEFAULT 0,
++ PRIMARY KEY (source, fingerprint)
++);
++
++CREATE TABLE tool_domains (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ tool_id text COLLATE "C" NOT NULL,
++ host text COLLATE "C" NOT NULL UNIQUE,
++ role text COLLATE "C" NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),
++ enabled bigint NOT NULL DEFAULT 1,
++ note text COLLATE "C",
++ created_by bigint,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ updated_at text COLLATE "C" DEFAULT ov_now()
++);
++
++CREATE TABLE user_history (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL,
++ service text COLLATE "C",
++ sub text COLLATE "C",
++ type text COLLATE "C" NOT NULL DEFAULT 'page',
++ title text COLLATE "C" NOT NULL,
++ url text COLLATE "C" NOT NULL,
++ icon text COLLATE "C",
++ meta text COLLATE "C",
++ hits bigint NOT NULL DEFAULT 1,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ FOREIGN KEY (user_id) REFERENCES users(id)
++);
++
++CREATE TABLE frame_cache (
++ key text COLLATE "C" PRIMARY KEY,
++ value text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" DEFAULT ov_now()
++);
++
++CREATE TABLE frame_hits (
++ day text COLLATE "C" NOT NULL,
++ host text COLLATE "C" NOT NULL,
++ hits bigint NOT NULL DEFAULT 0,
++ PRIMARY KEY (day, host)
++);
++
++CREATE UNIQUE INDEX idx_users_username_lower ON users(lower(username));
++CREATE INDEX idx_notif_user_read ON notifications(user_id, is_read, created_at DESC);
++CREATE INDEX idx_notif_user_cat ON notifications(user_id, category);
++CREATE INDEX idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL;
++CREATE INDEX idx_email_delivery_created ON email_delivery_log(created_at DESC);
++CREATE INDEX idx_email_delivery_status ON email_delivery_log(status, created_at DESC);
++CREATE INDEX idx_email_delivery_type ON email_delivery_log(email_type, created_at DESC);
++CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id, used_at, expires_at);
++CREATE INDEX idx_password_reset_expires ON password_reset_tokens(expires_at);
++CREATE INDEX idx_sessions_user ON user_sessions(user_id, is_active);
++CREATE INDEX idx_sessions_token ON user_sessions(session_token);
++CREATE INDEX idx_coin_tx_user ON coin_transactions(user_id, created_at DESC);
++CREATE INDEX idx_coin_tx_app ON coin_transactions(app_id, created_at DESC);
++CREATE INDEX idx_vkeys_key ON verification_keys(key);
++CREATE INDEX idx_vkeys_target ON verification_keys(target_username);
++CREATE INDEX idx_vkeys_status ON verification_keys(status);
++CREATE INDEX idx_anon_ip_log_ip ON anon_ip_log(ip);
++CREATE INDEX idx_anon_ip_log_anon ON anon_ip_log(anon_id);
++CREATE INDEX idx_push_sub_user ON push_subscriptions(user_id);
++CREATE INDEX idx_push_sub_endpoint ON push_subscriptions(endpoint);
++CREATE INDEX idx_oauth_tokens_family ON oauth_tokens(family_id);
++CREATE INDEX idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);
++CREATE UNIQUE INDEX idx_linked_user_service ON linked_accounts(user_id, service);
++CREATE INDEX idx_identity_legacy_subject ON identity_legacy_map(subject_id);
++CREATE UNIQUE INDEX idx_users_subject_id ON users(subject_id);
++CREATE UNIQUE INDEX idx_anon_users_subject_id ON anon_users(subject_id);
++CREATE INDEX idx_grant_changes_client ON principal_grant_changes(client_id, id);
++CREATE INDEX idx_dev_projects_owner ON dev_projects(owner_subject);
++CREATE INDEX idx_dev_members_subject ON dev_project_members(subject_id);
++CREATE INDEX idx_dev_apps_project ON dev_apps(project_id);
++CREATE INDEX idx_dev_credentials_app ON dev_credentials(app_id);
++CREATE INDEX idx_dev_audit_project ON dev_audit(project_id, id);
++CREATE INDEX idx_dev_usage_windows_when ON dev_usage_windows(window_start);
++CREATE INDEX idx_dev_usage_windows_project ON dev_usage_windows(project_id, recorded_at);
++CREATE INDEX idx_dev_usage_daily_day ON dev_usage_daily(project_id, day);
++CREATE UNIQUE INDEX idx_dev_usage_errors_once ON dev_usage_errors(project_id, env, service, capability, at, code, ref);
++CREATE INDEX idx_dev_usage_errors_recent ON dev_usage_errors(project_id, at);
++CREATE INDEX idx_user_blocks_blocked ON user_blocks(blocked_subject, active);
++CREATE INDEX idx_user_follows_target ON user_follows(target_type, target_id, active, created_at);
++CREATE INDEX idx_status_incident_updates ON status_incident_updates(incident_id, at);
++CREATE INDEX idx_creator_streams_creator ON creator_streams(creator_subject, started_at);
++CREATE INDEX idx_username_history_old ON username_history(lower(old_username));
++CREATE INDEX idx_username_history_user ON username_history(user_id);
++CREATE INDEX idx_user_profile_changes_user ON user_profile_changes(user_id);
++CREATE INDEX idx_subject_aliases_subject ON subject_aliases(subject_id);
++CREATE INDEX idx_account_exports_user ON account_exports(user_id, requested_at);
++CREATE UNIQUE INDEX idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';
++CREATE INDEX idx_sla_key ON stream_live_announcements(streamer_key, sent_at DESC);
++CREATE INDEX idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);
++CREATE INDEX idx_user_history_user_time ON user_history(user_id, updated_at DESC);
++CREATE INDEX idx_user_history_user_url ON user_history(user_id, url);
++
++CREATE FUNCTION user_modules_guard_users_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'; END IF; RETURN OLD; END $$;
++CREATE TRIGGER user_modules_guard_users_delete BEFORE DELETE ON users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_users_delete();
++
++CREATE FUNCTION user_modules_guard_users_rekey() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND NEW.subject_id IS DISTINCT FROM OLD.subject_id AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectMerged first'; END IF; RETURN NEW; END $$;
++CREATE TRIGGER user_modules_guard_users_rekey BEFORE UPDATE OF subject_id ON users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_users_rekey();
++
++CREATE FUNCTION user_modules_guard_anon_users_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'; END IF; RETURN OLD; END $$;
++CREATE TRIGGER user_modules_guard_anon_users_delete BEFORE DELETE ON anon_users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_anon_users_delete();
++
++CREATE FUNCTION user_modules_guard_anon_users_rekey() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND NEW.subject_id IS DISTINCT FROM OLD.subject_id AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectMerged first'; END IF; RETURN NEW; END $$;
++CREATE TRIGGER user_modules_guard_anon_users_rekey BEFORE UPDATE OF subject_id ON anon_users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_anon_users_rekey();
++
++CREATE FUNCTION dev_audit_append_only_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'dev_audit is append-only'; RETURN NEW; END $$;
++CREATE TRIGGER dev_audit_append_only_update BEFORE UPDATE ON dev_audit FOR EACH ROW EXECUTE FUNCTION dev_audit_append_only_update();
++
++CREATE FUNCTION dev_audit_append_only_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'dev_audit is append-only'; RETURN OLD; END $$;
++CREATE TRIGGER dev_audit_append_only_delete BEFORE DELETE ON dev_audit FOR EACH ROW EXECUTE FUNCTION dev_audit_append_only_delete();
++
++CREATE FUNCTION users_profile_changed() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF COALESCE(NEW.is_anon, 0) = 0 AND (OLD.username IS DISTINCT FROM NEW.username OR OLD.display_name IS DISTINCT FROM NEW.display_name OR OLD.avatar_url IS DISTINCT FROM NEW.avatar_url OR OLD.profile_color IS DISTINCT FROM NEW.profile_color OR OLD.role IS DISTINCT FROM NEW.role OR OLD.is_banned IS DISTINCT FROM NEW.is_banned) THEN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, trim((CASE WHEN OLD.username IS DISTINCT FROM NEW.username THEN 'username ' ELSE '' END) || (CASE WHEN OLD.display_name IS DISTINCT FROM NEW.display_name THEN 'display_name ' ELSE '' END) || (CASE WHEN OLD.avatar_url IS DISTINCT FROM NEW.avatar_url THEN 'avatar_url ' ELSE '' END) || (CASE WHEN OLD.profile_color IS DISTINCT FROM NEW.profile_color THEN 'profile_color ' ELSE '' END) || (CASE WHEN OLD.role IS DISTINCT FROM NEW.role THEN 'role ' ELSE '' END) || (CASE WHEN OLD.is_banned IS DISTINCT FROM NEW.is_banned THEN 'banned ' ELSE '' END)), (extract(epoch FROM statement_timestamp()) * 1000)::bigint); END IF; RETURN NULL; END $$;
++CREATE TRIGGER users_profile_changed AFTER UPDATE OF username, display_name, avatar_url, profile_color, role, is_banned ON users FOR EACH ROW EXECUTE FUNCTION users_profile_changed();
++
++CREATE FUNCTION users_profile_created() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF COALESCE(NEW.is_anon, 0) = 0 THEN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, 'created', (extract(epoch FROM statement_timestamp()) * 1000)::bigint); END IF; RETURN NULL; END $$;
++CREATE TRIGGER users_profile_created AFTER INSERT ON users FOR EACH ROW EXECUTE FUNCTION users_profile_created();
++
++-- The rows the SQLite boot seeded (built-in themes, default settings, the first-party OAuth clients with their
++-- per-install generated secrets, the platform's default principal grants) are not frozen here: server/db/database.js
++-- seeds them idempotently at boot, exactly as it did on SQLite, so each install generates its own client secrets.
++
++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
++CREATE TABLE IF NOT EXISTS network_event_inbox (
++ consumer text NOT NULL,
++ event_id text NOT NULL,
++ processed_at bigint NOT NULL,
++ PRIMARY KEY (consumer, event_id)
++);
++
++-- openvibe-sdk/events PostgreSQL outbox
++CREATE TABLE IF NOT EXISTS network_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 network_event_outbox_due ON network_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS network_event_outbox_sent ON network_event_outbox (sent_at) WHERE sent_at IS NOT NULL;
ddiff --git a/migrations/0002_analytics.sql b/migrations/0002_analytics.sql
nnew file mode 100644
iindex 00000000..c1ed0614
---- /dev/null
++++ b/migrations/0002_analytics.sql
@@@ -0,0 +1,56 @@
++-- phase: expand
++-- The five request-analytics tables (openvibe-shared/analytics/pg.js analyticsSchema()), so the runtime role creates none.
++-- Behavioural deltas from the SQLite tracker are in that module's header; the privacy bounds (DNT/GPC, path templating) are unchanged.
++CREATE TABLE IF NOT EXISTS analytics_events (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ service text NOT NULL,
++ event_type text NOT NULL DEFAULT 'pageview',
++ path text,
++ method text DEFAULT 'GET',
++ status_code integer,
++ response_time_ms integer,
++ user_id bigint,
++ session_id text,
++ ip text,
++ country text,
++ city text,
++ user_agent text,
++ referer text,
++ is_bot integer DEFAULT 0,
++ bot_type text,
++ device_type text,
++ browser text,
++ os text,
++ authenticated integer NOT NULL DEFAULT 0,
++ created_at text NOT NULL
++);
++CREATE TABLE IF NOT EXISTS analytics_hourly (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ service text NOT NULL, hour text NOT NULL,
++ pageviews bigint DEFAULT 0, api_calls bigint DEFAULT 0, unique_visitors bigint DEFAULT 0, unique_users bigint DEFAULT 0,
++ bot_hits bigint DEFAULT 0, avg_response_ms integer DEFAULT 0, error_count bigint DEFAULT 0, bandwidth_bytes bigint DEFAULT 0,
++ top_paths text, top_referers text,
++ UNIQUE (service, hour)
++);
++CREATE TABLE IF NOT EXISTS analytics_daily (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ service text NOT NULL, date text NOT NULL,
++ pageviews bigint DEFAULT 0, api_calls bigint DEFAULT 0, unique_visitors bigint DEFAULT 0, unique_users bigint DEFAULT 0,
++ new_users bigint DEFAULT 0, bot_hits bigint DEFAULT 0, avg_response_ms integer DEFAULT 0, error_count bigint DEFAULT 0,
++ top_paths text, top_referers text, top_countries text, device_breakdown text, browser_breakdown text,
++ UNIQUE (service, date)
++);
++CREATE TABLE IF NOT EXISTS analytics_visitor_days (
++ service text NOT NULL, day text NOT NULL, hour text NOT NULL, vhash text NOT NULL,
++ authenticated integer NOT NULL DEFAULT 0,
++ PRIMARY KEY (service, hour, vhash)
++);
++CREATE TABLE IF NOT EXISTS analytics_day_salts (
++ service text NOT NULL, day text NOT NULL, salt text NOT NULL,
++ PRIMARY KEY (service, day)
++);
++CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events (created_at);
++CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events (service, created_at);
++CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events (service, path, created_at);
++CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events (is_bot, created_at);
++CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_days (service, day);
ddiff --git a/package-lock.json b/package-lock.json
iindex 50211780..55028297 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,13 +18,18 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
++ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
++ "pg": "^8.23.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
}
},
"node_modules/@discordjs/builders": {
@@@ -157,6 +162,19 @@
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
++ "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/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@@ -489,6 +507,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/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@@ -609,6 +636,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",
@@@ -1126,6 +1162,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",
@@@ -1202,12 +1281,24 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
++ "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",
@@@ -1481,12 +1572,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.25.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
++ "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==",
"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": {
@@@ -1513,6 +1624,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",
@@@ -1645,6 +1884,27 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
++ "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",
@@@ -1860,6 +2120,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",
ddiff --git a/package.json b/package.json
iindex e7a6d73f..c9185381 100644
---- a/package.json
++++ b/package.json
@@@ -8,7 +8,10 @@
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/nodes.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/home-empty-catalog.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"reset-db": "node server/reset-db.js",
-- "grant-admin": "node server/grant-admin.js"
++ "grant-admin": "node server/grant-admin.js",
++ "test:pg": "NETWORK_TEST_STORE=pg node test/run.js",
++ "migrate-to-postgres": "node scripts/migrate-to-postgres.js",
++ "pg-preflight": "node scripts/pg-preflight-collisions.js"
},
"license": "MIT",
"dependencies": {
@@@ -21,12 +24,17 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
++ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
++ "pg": "^8.23.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
}
}
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100644
iindex 00000000..725694c6
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -0,0 +1,42 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The one-time move of Network's SQLite database (DB_PATH) into its PostgreSQL schema (plan T2, ADR-035; the
++ * procedure is openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by runSqliteMigration).
++ * Opus runs it at the cutover, after scripts/pg-preflight-collisions.js.
++ *
++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
++ *
++ * --pglite a dry run: an in-memory PostgreSQL, migrations applied, every table imported with truncate
++ * and verified, and the per-table count + checksum report printed. Nothing to set up, nothing
++ * left behind. Use it to prove the schema is importable before touching a real target.
++ * --sqlite the source file (default config.db.path). It is opened read-only; nothing in it changes.
++ * --json the report as JSON.
++ *
++ * Without --pglite it applies migrations/ as the owner on DATABASE_DIRECT_URL (the serving DATABASE_URL goes
++ * through PgBouncer in transaction mode, which cannot hold the import's transaction) and copies into emptied
++ * tables, so a rehearsal against a scratch DATABASE_URL is repeatable. Exit 0 only if every table verified.
++ *
++ * The legacy integer-keyed `follows` table is imported like every other: server/auth/routes.js and
++ * server/internal/routes.js still read it (the subject-keyed user_follows is the forward model).
++ */
++require('dotenv').config();
++const path = require('path');
++const { runSqliteMigration } = require('openvibe-sdk/db');
++const config = require('../server/config');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++
++// importSqlite's per-table options. Empty: every PostgreSQL column is fed by the same-named SQLite one.
++const TABLES = {};
++
++// SQLite tables the import leaves behind. Empty: even the SDK's SQLite outbox/inbox rows are carried over
++// (unsent events keep their place in the PostgreSQL outbox).
++const SKIP_SOURCE = [];
++
++if (require.main === module) {
++ runSqliteMigration({ service: 'network', sqlite: config.db.path, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES, skipSource: SKIP_SOURCE })
++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
++}
++
++module.exports = { TABLES, SKIP_SOURCE, MIGRATIONS };
ddiff --git a/scripts/pg-preflight-collisions.js b/scripts/pg-preflight-collisions.js
nnew file mode 100644
iindex 00000000..bc94e887
---- /dev/null
++++ b/scripts/pg-preflight-collisions.js
@@@ -0,0 +1,66 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Before the PostgreSQL cutover (plan T2, ADR-035): SQLite's `COLLATE NOCASE` made some columns compare
++ * case-insensitively, and username uniqueness inherited it, so `Alex` and `alex` cannot both exist here.
++ * PostgreSQL compares text case-sensitively; the port keeps the behaviour with a unique index on
++ * lower(username) and every lookup rewritten to lower(). That only works if the existing data has no two
++ * rows that fold together — this lists them, so they are fixed on the SQLite side before the import.
++ *
++ * node scripts/pg-preflight-collisions.js [--sqlite path/to/network.db] [--json]
++ *
++ * Exit 0 = no collisions; 1 = at least one (the cutover must wait); 2 = unusable input. Read-only: the
++ * SQLite file is opened in read-only mode and never written. Reports, for each case-insensitive column that
++ * must stay unique, the folding keys with more than one distinct spelling.
++ */
++require('dotenv').config();
++const fs = require('fs');
++const config = require('../server/config');
++
++const args = process.argv.slice(2);
++const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };
++const file = opt('--sqlite', config.db.path);
++const asJson = args.includes('--json');
++
++/** [table, column, the WHERE the uniqueness applies under, why it must not fold]. */
++const CHECKS = [
++ ['users', 'username', '1 = 1', 'users.username is UNIQUE: two spellings folding together would make the column ambiguous'],
++ ['verification_keys', 'target_username', "status = 'active'", 'an active reserved-username claim would match two people'],
++ ['username_history', 'old_username', '1 = 1', 'a former name would resolve to two accounts'],
++];
++
++function main() {
++ if (!fs.existsSync(file)) { console.error(`no such SQLite database: ${file}`); return 2; }
++ let Database;
++ try { Database = require('better-sqlite3'); } catch { console.error('better-sqlite3 is not installed; run npm install'); return 2; }
++ let db;
++ try { db = new Database(file, { readonly: true, fileMustExist: true }); } catch (e) { console.error(`cannot open ${file}: ${e.message}`); return 2; }
++
++ const report = { sqlite: file, checked: [], collisions: [] };
++ for (const [table, column, where, why] of CHECKS) {
++ const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
++ if (!exists) { report.checked.push({ table, column, rows: 0, note: 'table absent' }); continue; }
++ const rows = db.prepare(`SELECT lower(${column}) AS folded, COUNT(DISTINCT ${column}) AS spellings,
++ group_concat(DISTINCT ${column}) AS names, COUNT(*) AS rows
++ FROM ${table} WHERE ${where} GROUP BY folded HAVING COUNT(DISTINCT ${column}) > 1
++ ORDER BY folded`).all();
++ report.checked.push({ table, column, rows: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get().n });
++ for (const r of rows) report.collisions.push({ table, column, folded: r.folded, spellings: r.spellings, names: r.names, rows: r.rows, why });
++ }
++ db.close();
++
++ if (asJson) { console.log(JSON.stringify(report, null, 2)); return report.collisions.length ? 1 : 0; }
++
++ console.log(`pg-preflight-collisions: ${file}`);
++ for (const c of report.checked) console.log(` ${c.table}.${c.column}: ${c.rows} row(s)${c.note ? ` (${c.note})` : ''}`);
++ if (!report.collisions.length) { console.log('no case-fold collisions: safe to import'); return 0; }
++ console.log(`\n${report.collisions.length} case-fold collision(s) — resolve these on the SQLite side first:`);
++ for (const c of report.collisions) {
++ console.log(` ${c.table}.${c.column}: ${c.names} (fold "${c.folded}", ${c.rows} rows)`);
++ console.log(` ${c.why}`);
++ }
++ return 1;
++}
++
++if (require.main === module) process.exit(main());
++module.exports = { CHECKS };
ddiff --git a/server/config.js b/server/config.js
iindex 74428f3f..ec46b4e1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -53,9 +53,24 @@ module.exports = {
// scripts/subscribe-events.js.
eventsWebhookSecrets: process.env.NETWORK_EVENTS_SECRET || '',
-- // Database
++ // Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves
++ // requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.
++ // Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`
++ // (PGLITE_DIR overrides it); production refuses to boot. `path` is the retired SQLite file: it is
++ // read only by the one-time import (scripts/migrate-to-postgres.js) and reset-db, never by serving code.
db: {
path: process.env.DB_PATH || './data/network.db',
++ url: process.env.DATABASE_URL || '',
++ directUrl: process.env.DATABASE_DIRECT_URL || '',
++ pgliteDir: process.env.PGLITE_DIR || 'data/pglite',
++ },
++
++ // Valkey (ADR-035, plan T2): the shared store behind the per-actor limits and any short-lived shared
++ // state, so every process and host counts one actor together. Unset = this process's own counters
++ // (and, for the limits, a Valkey outage degrades to them rather than answering 500). PostgreSQL stays
++ // the source of truth; nothing here is a cache of record.
++ valkey: {
++ url: process.env.VALKEY_URL || '',
},
// Admin auto-creation
ddiff --git a/test/helpers/pg-preload.mjs b/test/helpers/pg-preload.mjs
nnew file mode 100644
iindex 00000000..320b9bdb
---- /dev/null
++++ b/test/helpers/pg-preload.mjs
@@@ -0,0 +1,21 @@
++// Loaded with `node --import` into every test process (test/run.js): one migrated database for the process
++// (plan T2, ADR-035) — PGlite by default, or, with NETWORK_TEST_STORE=pg (npm run test:pg), the PostgreSQL +
++// PgBouncer containers with roles and a schema of its own. server/db/database.js initDb() adopts it, so test
++// files open no database themselves. The process ends with the test (a container schema is dropped by
++// `scripts/test-services.sh down`).
++import { createRequire } from 'node:module';
++import path from 'node:path';
++import { fileURLToPath } from 'node:url';
++
++const require = createRequire(import.meta.url);
++const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
++const { createTestDb } = require('openvibe-sdk/testing');
++const t = await createTestDb({ migrations: path.join(root, 'migrations'), store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', max: 4 });
++// Tests insert rows with small explicit ids. A PostgreSQL identity would continue after them, so generated ids
++// are set to start at 100000 here, clear of every id a test names.
++for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(table_name), column_name) AS seq FROM information_schema.columns
++ WHERE table_schema = current_schema() AND is_identity = 'YES'`).all()) {
++ if (r.seq) await t.db.prepare('SELECT setval(?::regclass, 100000)').get(r.seq);
++}
++globalThis.__ovNetworkTestDb = t.db;
++globalThis.__ovNetworkTestDbClose = t.close;
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 00000000..5bbeab86
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,18 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — one at a time, each in its own process, and fails if
++ * any of them fails (openvibe-shared/test-runner).
++ *
++ * npm test # everything
++ * npm test -- usernames follows # only files whose name contains one of the words
++ * npm test -- --strict # a skipped test fails the run too
++ *
++ * Each test gets a migrated PostgreSQL database of its own (PGlite; the containers under npm run test:pg) and
++ * local servers on random ports. A test that cannot run something prints `<label>: skipped (<why>)` and is
++ * listed with ○, not counted as passed.
++ */
++'use strict';
++const { pathToFileURL } = require('url');
++// Every test process starts with a migrated database of its own (test/helpers/pg-preload.mjs; plan T2, ADR-035).
++const preload = pathToFileURL(require('path').join(__dirname, 'helpers', 'pg-preload.mjs')).href;
++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: 1, hide: /^\[DB\] /, nodeArgs: ['--import', preload] });