openvibers/openvibe.trade Commits
Trade moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.
Changes
@@@ -20,6 +20,14 @@ TRUST_PROXY=2#TRADE_LIMITS_HOUR=3000# SQLite database (production unit: /var/lib/openvibe-trade/trade.db)++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh trade: the runtime role through PgBouncer,++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.TRADE_DB_PATH=./data/trade.db# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens@@@ -9,6 +9,9 @@ jobs:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:contracts-service: trade++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176@@@ -29,7 +29,7 @@ not as a table. The historical marketplace branch is recorded as an open questio## Owns--The nine charter tables (roadmap §15.13) live in Trade's own SQLite (`TRADE_DB_PATH`):++The nine charter tables (roadmap §15.13) live in Trade's own PostgreSQL database (`ov_trade` on the host's data role, ADR-035; schema in [migrations/](migrations/)):| Charter table | What it is ||---|---|@@@ -208,9 +208,11 @@ services need to call Trade, is under [Grants the Network must hold](#grants-the## Depends on--- **Packages** (pinned by release tarball): `openvibe-contracts` v0.49.0, `openvibe-publishing`-- v0.4.0 (revisions, authorship, seo, index-hooks, ssr), `openvibe-shared` v1.25.0 (chrome, app icon,-- footer, legal, release, metrics, ready), `openvibe-sdk` v0.12.0 (events outbox and inbox, webhook++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through++ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).++- **Packages** (pinned by release tarball): `openvibe-contracts` v0.76.0, `openvibe-publishing`++ v1.0.0 (revisions, authorship, seo, index-hooks, ssr), `openvibe-shared` v1.25.0 (Frame, app icon,++ footer, legal, release, metrics, ready), `openvibe-sdk` v0.20.0 (events outbox and inbox, webhooksignatures v2, service tokens, per-actor limits).- **OpenVibe.Network:** SSO (OAuth client `trade`, redirect `https://openvibe.trade/auth/callback`),JWKS, client-credentials tokens.@@@ -342,10 +344,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4860 (set OV_OAUTHProduction deploys with `sudo ovhost deploy trade` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.trade`, install on a lockfile change, restart, wait for `/api/ready`).--The unit is `openvibe-trade.service` on `127.0.0.1:4860`, the env file `/etc/openvibe/trade.env`.++The unit is `openvibe-trade.service` on `127.0.0.1:4860`, the env file `/etc/openvibe/trade.env`. The database is++`ov_trade` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh trade` writes its settings); the++release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk++`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old++`/var/lib/openvibe-trade/trade.db` stays read-only for 7 days as the rollback.Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the--restart; afterwards `sudo ovhost rollback trade --to <sha>`. Nothing blocks a rollback: the schema--code only adds tables and columns.++restart; afterwards `sudo ovhost rollback trade --to <sha>`. Migrations only add tables and columns.First install (done once; kept for a rebuild):@@@ -4,12 +4,17 @@"code": true,"wave": "W19","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express 4, better-sqlite3 11",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4860,"domain": "openvibe.trade","scope": "informational only (ADR-025): no custody, no order execution, no escrow, no listings between users, no personalised advice",-- "contracts": "openvibe-contracts v0.49.0",-- "packages": ["openvibe-contracts v0.49.0", "openvibe-publishing v0.4.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],++ "contracts": "openvibe-contracts v0.76.0",++ "packages": [++ "openvibe-contracts v0.76.0",++ "openvibe-publishing v1.0.0",++ "openvibe-shared v1.25.0",++ "openvibe-sdk v0.20.0"++ ],"capabilities": ["trade.instrument.resolve","trade.instrument.manage",@@@ -3,8 +3,8 @@ Description=OpenVibe.Trade — informational market context (no custody, no orde# Network (sign-in, JWKS, service tokens), Sources (the trade category) and Events (the outbox# relay) should be up first, but Trade degrades gracefully without them: pages and feeds serve what# was recorded, marked stale as it ages; sign-in, sync and events wait (see /api/ready).--After=network.target--Wants=openvibe-network.service++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service[Service]Type=simplennew file mode 100644@@@ -0,0 +1,272 @@++-- phase: expand++-- OpenVibe.Trade on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.++++CREATE TABLE trade_instruments (++ id text COLLATE "C" PRIMARY KEY, -- ins_<ULID>++ symbol text COLLATE "C" NOT NULL UNIQUE, -- canonical display ticker, upper case: /i/:symbol++ name text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('equity','fund','index','currency','commodity','crypto','other')),++ exchange text COLLATE "C", -- as an editor recorded it; NULL = not recorded++ cik text COLLATE "C" UNIQUE, -- SEC Central Index Key, 10 digits zero-padded++ currency text COLLATE "C", -- ISO 4217 trading currency, when recorded++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),++ context_published_revision bigint, -- trade_context_revisions.number readers see++ context_published_at bigint,++ created_by text COLLATE "C", -- usr_… or svc:…++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++CREATE TABLE trade_instrument_aliases (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('ticker','cik','name')),++ value text COLLATE "C" NOT NULL, -- as entered++ normalized text COLLATE "C" NOT NULL, -- the resolution key (see domain/instruments.js)++ added_by text COLLATE "C",++ created_at bigint NOT NULL,++ UNIQUE (instrument_id, kind, normalized)++);++-- A ticker or CIK names exactly one instrument; a name may be shared (resolution is then ambiguous).++CREATE UNIQUE INDEX trade_aliases_unique_key ON trade_instrument_aliases (kind, normalized) WHERE kind IN ('ticker','cik');++CREATE INDEX trade_aliases_lookup ON trade_instrument_aliases (kind, normalized);++++CREATE TABLE trade_watchlists (++ id text COLLATE "C" PRIMARY KEY, -- wl_<ULID>++ owner_subject text COLLATE "C" NOT NULL, -- usr_…; never shown to anyone else++ name text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ UNIQUE (owner_subject, name)++);++++CREATE TABLE trade_watchlist_items (++ watchlist_id text COLLATE "C" NOT NULL REFERENCES trade_watchlists(id) ON DELETE CASCADE,++ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),++ note text COLLATE "C",++ added_at bigint NOT NULL,++ PRIMARY KEY (watchlist_id, instrument_id)++);++++CREATE TABLE trade_market_observations (++ id text COLLATE "C" PRIMARY KEY, -- obs_<ULID>++ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),++ metric text COLLATE "C" NOT NULL, -- e.g. price.close, volume, us-gaap:Revenues++ value text COLLATE "C" NOT NULL, -- the decimal exactly as the source stated it++ value_num double precision NOT NULL, -- the same value, for comparisons only++ unit text COLLATE "C" NOT NULL, -- USD, shares, USD/share, pure, …++ currency text COLLATE "C", -- ISO 4217 when the value is monetary++ period text COLLATE "C", -- e.g. 2026-Q2, when the source states one++ observed_at bigint NOT NULL, -- when the value was true, per the source++ source_key text COLLATE "C" NOT NULL, -- OpenVibe.Sources source key++ source_item_id text COLLATE "C", -- itm_… when it came from a Sources item++ source_url text COLLATE "C",++ source_ref text COLLATE "C" NOT NULL, -- the source's own identity of this datum++ retrieved_at bigint NOT NULL, -- when the source was fetched++ max_age_sec bigint, -- the value is stale after this long (NULL: only the source's freshness)++ recorded_by text COLLATE "C" NOT NULL, -- svc:… (a feed) or usr_… (never inferred)++ recorded_at bigint NOT NULL,++ UNIQUE (source_key, source_ref)++);++CREATE INDEX trade_obs_series ON trade_market_observations (instrument_id, metric, observed_at);++CREATE FUNCTION trade_obs_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'trade_market_observations rows are immutable'; END $$;++CREATE TRIGGER trade_obs_no_update BEFORE UPDATE ON trade_market_observations FOR EACH ROW EXECUTE FUNCTION trade_obs_no_update();++CREATE FUNCTION trade_obs_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'trade_market_observations rows are immutable'; END $$;++CREATE TRIGGER trade_obs_no_delete BEFORE DELETE ON trade_market_observations FOR EACH ROW EXECUTE FUNCTION trade_obs_no_delete();++++CREATE TABLE trade_source_documents (++ id text COLLATE "C" PRIMARY KEY, -- doc_<ULID>++ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),++ source_key text COLLATE "C" NOT NULL,++ source_item_id text COLLATE "C" NOT NULL UNIQUE, -- itm_…: Sources owns the item++ source_revision bigint NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('filing','article','record')),++ form_type text COLLATE "C", -- 10-K, 8-K, … when the source states it++ title text COLLATE "C",++ filer_name text COLLATE "C",++ cik text COLLATE "C",++ accession text COLLATE "C",++ url text COLLATE "C",++ published_at bigint, -- the source's own date; NULL if it gave none++ source_updated_at bigint,++ retrieved_at bigint NOT NULL,++ first_seen_at bigint NOT NULL,++ license_note text COLLATE "C",++ terms_note text COLLATE "C",++ removed_at bigint,++ removed_reason text COLLATE "C",++ recorded_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX trade_docs_instrument ON trade_source_documents (instrument_id, removed_at, published_at);++++CREATE TABLE trade_alert_rules (++ id text COLLATE "C" PRIMARY KEY, -- alr_<ULID>++ owner_subject text COLLATE "C" NOT NULL,++ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('threshold','filing_type','new_document')),++ metric text COLLATE "C",++ operator text COLLATE "C" CHECK (operator IN ('above','below')),++ threshold text COLLATE "C",++ threshold_num double precision,++ unit text COLLATE "C",++ currency text COLLATE "C",++ form_types text COLLATE "C", -- JSON array (filing_type rules)++ armed bigint NOT NULL DEFAULT 1, -- threshold rules fire on a crossing, then re-arm++ last_observed_at bigint, -- newest observation a threshold rule evaluated++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ CHECK (kind <> 'threshold' OR (metric IS NOT NULL AND operator IS NOT NULL AND threshold IS NOT NULL AND unit IS NOT NULL)),++ CHECK (kind <> 'filing_type' OR form_types IS NOT NULL)++);++CREATE INDEX trade_rules_owner ON trade_alert_rules (owner_subject, status);++CREATE INDEX trade_rules_instrument ON trade_alert_rules (instrument_id, status, kind);++++CREATE TABLE trade_alert_deliveries (++ id text COLLATE "C" PRIMARY KEY, -- ald_<ULID>++ rule_id text COLLATE "C" NOT NULL REFERENCES trade_alert_rules(id),++ owner_subject text COLLATE "C" NOT NULL,++ trigger_kind text COLLATE "C" NOT NULL CHECK (trigger_kind IN ('observation','document')),++ trigger_id text COLLATE "C" NOT NULL,++ event_id text COLLATE "C", -- the trade.alert.triggered envelope++ summary text COLLATE "C" NOT NULL, -- JSON snapshot of what triggered it++ created_at bigint NOT NULL,++ UNIQUE (rule_id, trigger_kind, trigger_id)++);++CREATE INDEX trade_deliveries_owner ON trade_alert_deliveries (owner_subject, created_at);++++-- Freshness of each source Trade shows data from (not a charter table; a projection of Sources'++-- health plus Trade's own clock).++CREATE TABLE trade_source_status (++ source_key text COLLATE "C" PRIMARY KEY,++ name text COLLATE "C",++ upstream_status text COLLATE "C", -- Sources' health status, NULL = never reported++ last_success_at bigint, -- last successful fetch (Sources, or the newest retrieval Trade recorded)++ stale_after_sec bigint,++ reported_at bigint, -- when Sources last told us about it++ stale bigint NOT NULL DEFAULT 1, -- as of the last evaluation++ stale_since bigint,++ evaluated_at bigint,++ terms_note text COLLATE "C",++ license_note text COLLATE "C"++);++++CREATE TABLE trade_sync_state (++ name text COLLATE "C" PRIMARY KEY,++ cursor bigint NOT NULL DEFAULT 0,++ last_ok_at bigint,++ last_error text COLLATE "C",++ last_error_at bigint,++ counts text COLLATE "C" NOT NULL DEFAULT '{}'++);++++-- openvibe-publishing/revisions (prefix trade_context)++CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_revisions_entity_num ON trade_context_revisions (entity_id, number);++CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_drafts_updated ON trade_context_drafts (entity_id, updated_at DESC, owner);++CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ IF TG_OP = 'UPDATE' THEN++ RAISE EXCEPTION 'trade_context_revisions rows are immutable' USING ERRCODE = 'restrict_violation';++ END IF;++ IF NOT EXISTS (SELECT 1 FROM trade_context_revision_purges WHERE entity_id = OLD.entity_id) THEN++ RAISE EXCEPTION 'trade_context_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';++ END IF;++ RETURN OLD;++END++$$;++CREATE OR REPLACE TRIGGER trade_context_revisions_no_update BEFORE UPDATE ON trade_context_revisions FOR EACH ROW EXECUTE FUNCTION trade_context_revisions_guard();++CREATE OR REPLACE TRIGGER trade_context_revisions_no_delete BEFORE DELETE ON trade_context_revisions FOR EACH ROW EXECUTE FUNCTION trade_context_revisions_guard();++++-- openvibe-publishing/authorship (prefix trade_context)++CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_reviews_rev ON trade_context_reviews (entity_id, revision, id);++CREATE INDEX IF NOT EXISTS trade_context_reviews_entity ON trade_context_reviews (entity_id, id);++CREATE OR REPLACE FUNCTION trade_context_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ IF TG_OP = 'UPDATE' THEN++ RAISE EXCEPTION 'trade_context_reviews rows are immutable' USING ERRCODE = 'restrict_violation';++ END IF;++ RETURN OLD;++END++$$;++CREATE OR REPLACE TRIGGER trade_context_reviews_no_update BEFORE UPDATE ON trade_context_reviews FOR EACH ROW EXECUTE FUNCTION trade_context_reviews_guard();++++-- openvibe-publishing/index-hooks (prefix trade)++CREATE TABLE IF NOT EXISTS trade_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)++);++++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled++CREATE TABLE IF NOT EXISTS idempotency_receipts (++ consumer text NOT NULL,++ event_id text NOT NULL,++ processed_at bigint NOT NULL,++ PRIMARY KEY (consumer, event_id)++);++++-- openvibe-sdk/events PostgreSQL outbox++CREATE TABLE IF NOT EXISTS event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;@@@ -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.49.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.20.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.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "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.20.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",++ "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==","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,14 +2,21 @@"name": "openvibe-trade","version": "0.1.0","private": true,-- "description": "OpenVibe.Trade — informational market context: instruments and aliases, timestamped market observations with their sources, filings from OpenVibe.Sources, private watchlists and alerts, and reviewed context. Information only: no custody, no order execution, no advice.",++ "description": "OpenVibe.Trade \u2014 informational market context: instruments and aliases, timestamped market observations with their sources, filings from OpenVibe.Sources, private watchlists and alerts, and reviewed context. Information only: no custody, no order execution, no advice.","main": "server/index.js","scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/run.js"++ "test": "node test/run.js",++ "test:pg": "TRADE_TEST_STORE=pg node test/run.js"},-- "keywords": ["openvibe", "trade", "watchlists", "filings", "informational"],++ "keywords": [++ "openvibe",++ "trade",++ "watchlists",++ "filings",++ "informational"++ ],"author": "OpenVibers","license": "AGPL-3.0-only","repository": "github:OpenVibers/OpenVibe.Trade",@@@ -20,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.49.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.20.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"nnew file mode 100644@@@ -0,0 +1,26 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Trade's SQLite database (TRADE_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is++ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).++ *++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]++ *++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts++ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its++ * name and columns.++ */++require('dotenv').config();++const { runSqliteMigration } = require('openvibe-sdk/db');++const configLib = require('../server/config');++const { MIGRATIONS } = require('../server/db');++++const TABLES = {};++++if (require.main === module) {++ const config = configLib.load();++ runSqliteMigration({ service: 'trade', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });++}++++module.exports = { TABLES };@@@ -57,11 +57,12 @@ const VERSION = require('../package.json').version;/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,* limitsNow (the per-actor limiter's clock, tests; default the wall clock) */--function createApp(opts = {}) {++async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;const fetchImpl = opts.fetchImpl || globalThis.fetch;-- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.++ const store = opts.store || await openStore(config, { now: opts.now, log });// One context object: domain modules reach each other through it at call time.const ctx = { config, store, log };@@@ -93,7 +94,10 @@ function createApp(opts = {}) {app.locals.ctx = ctx;// Per-actor limits (http/actor-limits.js) on /api/v1 and the forms, counted once each router resolved// req.viewer; the per-address limits below stay.-- ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++ ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });app.use(contracts.http.middleware());app.use(helmet({@@@ -127,7 +131,7 @@ function createApp(opts = {}) {// GET /release.json (ADR-016) and POST /release-metrics (release_client_updates_total in /metrics).define(machine, 'get', '/release.json', 'releaseInfo', release.handler);define(machine, 'post', '/release-metrics', 'releaseMetrics', release.collect(metrics.registry));-- const readiness = createTradeReadiness({ store, auth: ctx.auth, outbox: ctx.outbox, sync: ctx.sync, freshness: ctx.freshness, release: release.release });++ const readiness = createTradeReadiness({ store, auth: ctx.auth, outbox: ctx.outbox, sync: ctx.sync, freshness: ctx.freshness, release: release.release, valkey: ctx.valkey });define(machine, 'get', '/api/ready', 'readiness', readiness.handler);app.use(machine);@@@ -81,7 +81,7 @@ function createViewerResolver({ auth, config }) {const payload = decodeJwtPayload(token);if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {if (opts.services === false) return ANONYMOUS;-- return fromServiceToken(req, token);++ return await fromServiceToken(req, token);}}const token = extractToken(req);@@@ -34,8 +34,8 @@ function createSourcesClient({ config, fetchImpl = globalThis.fetch }) {return {enabled,-- items: ({ after = 0, limit = 200 } = {}) => get(`/api/v1/items?category=${encodeURIComponent(config.sources.category)}&after=${after}&limit=${limit}&include_removed=1`),-- sources: () => get('/api/v1/sources'),++ items: async ({ after = 0, limit = 200 } = {}) => await get(`/api/v1/items?category=${encodeURIComponent(config.sources.category)}&after=${after}&limit=${limit}&include_removed=1`),++ sources: async () => await get('/api/v1/sources'),};}@@@ -35,6 +35,10 @@ function load(env = process.env) {hour: Math.max(1, int(env.TRADE_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:trade:' },++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.dbPath: env.TRADE_DB_PATH || './data/trade.db',// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.@@@ -1,7 +1,8 @@'use strict';/**-- * Trade's own SQLite database: created on boot, idempotently. Nothing here is shared with another++ * Trade'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 nine charter tables (roadmap §15.13):@@@ -27,196 +28,55 @@*/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 { createReviewLog } = require('openvibe-publishing/authorship');const { createIndexSequencer } = require('openvibe-publishing/index-hooks');--const SCHEMA = `--CREATE TABLE IF NOT EXISTS trade_instruments (-- id TEXT PRIMARY KEY, -- ins_<ULID>-- symbol TEXT NOT NULL UNIQUE, -- canonical display ticker, upper case: /i/:symbol-- name TEXT NOT NULL,-- kind TEXT NOT NULL CHECK (kind IN ('equity','fund','index','currency','commodity','crypto','other')),-- exchange TEXT, -- as an editor recorded it; NULL = not recorded-- cik TEXT UNIQUE, -- SEC Central Index Key, 10 digits zero-padded-- currency TEXT, -- ISO 4217 trading currency, when recorded-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),-- context_published_revision INTEGER, -- trade_context_revisions.number readers see-- context_published_at INTEGER,-- created_by TEXT, -- usr_… or svc:…-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');--CREATE TABLE IF NOT EXISTS trade_instrument_aliases (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),-- kind TEXT NOT NULL CHECK (kind IN ('ticker','cik','name')),-- value TEXT NOT NULL, -- as entered-- normalized TEXT NOT NULL, -- the resolution key (see domain/instruments.js)-- added_by TEXT,-- created_at INTEGER NOT NULL,-- UNIQUE (instrument_id, kind, normalized)--);--CREATE UNIQUE INDEX IF NOT EXISTS trade_aliases_unique_key ON trade_instrument_aliases (kind, normalized) WHERE kind IN ('ticker','cik');--CREATE INDEX IF NOT EXISTS trade_aliases_lookup ON trade_instrument_aliases (kind, normalized);----CREATE TABLE IF NOT EXISTS trade_watchlists (-- id TEXT PRIMARY KEY, -- wl_<ULID>-- owner_subject TEXT NOT NULL, -- usr_…; never shown to anyone else-- name TEXT NOT NULL,-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- UNIQUE (owner_subject, name)--);----CREATE TABLE IF NOT EXISTS trade_watchlist_items (-- watchlist_id TEXT NOT NULL REFERENCES trade_watchlists(id) ON DELETE CASCADE,-- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),-- note TEXT,-- added_at INTEGER NOT NULL,-- PRIMARY KEY (watchlist_id, instrument_id)--);----CREATE TABLE IF NOT EXISTS trade_market_observations (-- id TEXT PRIMARY KEY, -- obs_<ULID>-- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),-- metric TEXT NOT NULL, -- e.g. price.close, volume, us-gaap:Revenues-- value TEXT NOT NULL, -- the decimal exactly as the source stated it-- value_num REAL NOT NULL, -- the same value, for comparisons only-- unit TEXT NOT NULL, -- USD, shares, USD/share, pure, …-- currency TEXT, -- ISO 4217 when the value is monetary-- period TEXT, -- e.g. 2026-Q2, when the source states one-- observed_at INTEGER NOT NULL, -- when the value was true, per the source-- source_key TEXT NOT NULL, -- OpenVibe.Sources source key-- source_item_id TEXT, -- itm_… when it came from a Sources item-- source_url TEXT,-- source_ref TEXT NOT NULL, -- the source's own identity of this datum-- retrieved_at INTEGER NOT NULL, -- when the source was fetched-- max_age_sec INTEGER, -- the value is stale after this long (NULL: only the source's freshness)-- recorded_by TEXT NOT NULL, -- svc:… (a feed) or usr_… (never inferred)-- recorded_at INTEGER NOT NULL,-- UNIQUE (source_key, source_ref)--);--CREATE INDEX IF NOT EXISTS trade_obs_series ON trade_market_observations (instrument_id, metric, observed_at);--CREATE TRIGGER IF NOT EXISTS trade_obs_no_update BEFORE UPDATE ON trade_market_observations-- BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;--CREATE TRIGGER IF NOT EXISTS trade_obs_no_delete BEFORE DELETE ON trade_market_observations-- BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;----CREATE TABLE IF NOT EXISTS trade_source_documents (-- id TEXT PRIMARY KEY, -- doc_<ULID>-- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),-- source_key TEXT NOT NULL,-- source_item_id TEXT NOT NULL UNIQUE, -- itm_…: Sources owns the item-- source_revision INTEGER NOT NULL,-- kind TEXT NOT NULL CHECK (kind IN ('filing','article','record')),-- form_type TEXT, -- 10-K, 8-K, … when the source states it-- title TEXT,-- filer_name TEXT,-- cik TEXT,-- accession TEXT,-- url TEXT,-- published_at INTEGER, -- the source's own date; NULL if it gave none-- source_updated_at INTEGER,-- retrieved_at INTEGER NOT NULL,-- first_seen_at INTEGER NOT NULL,-- license_note TEXT,-- terms_note TEXT,-- removed_at INTEGER,-- removed_reason TEXT,-- recorded_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS trade_docs_instrument ON trade_source_documents (instrument_id, removed_at, published_at);----CREATE TABLE IF NOT EXISTS trade_alert_rules (-- id TEXT PRIMARY KEY, -- alr_<ULID>-- owner_subject TEXT NOT NULL,-- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),-- kind TEXT NOT NULL CHECK (kind IN ('threshold','filing_type','new_document')),-- metric TEXT,-- operator TEXT CHECK (operator IN ('above','below')),-- threshold TEXT,-- threshold_num REAL,-- unit TEXT,-- currency TEXT,-- form_types TEXT, -- JSON array (filing_type rules)-- armed INTEGER NOT NULL DEFAULT 1, -- threshold rules fire on a crossing, then re-arm-- last_observed_at INTEGER, -- newest observation a threshold rule evaluated-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- CHECK (kind <> 'threshold' OR (metric IS NOT NULL AND operator IS NOT NULL AND threshold IS NOT NULL AND unit IS NOT NULL)),-- CHECK (kind <> 'filing_type' OR form_types IS NOT NULL)--);--CREATE INDEX IF NOT EXISTS trade_rules_owner ON trade_alert_rules (owner_subject, status);--CREATE INDEX IF NOT EXISTS trade_rules_instrument ON trade_alert_rules (instrument_id, status, kind);----CREATE TABLE IF NOT EXISTS trade_alert_deliveries (-- id TEXT PRIMARY KEY, -- ald_<ULID>-- rule_id TEXT NOT NULL REFERENCES trade_alert_rules(id),-- owner_subject TEXT NOT NULL,-- trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('observation','document')),-- trigger_id TEXT NOT NULL,-- event_id TEXT, -- the trade.alert.triggered envelope-- summary TEXT NOT NULL, -- JSON snapshot of what triggered it-- created_at INTEGER NOT NULL,-- UNIQUE (rule_id, trigger_kind, trigger_id)--);--CREATE INDEX IF NOT EXISTS trade_deliveries_owner ON trade_alert_deliveries (owner_subject, created_at);----CREATE TABLE IF NOT EXISTS trade_source_status (-- source_key TEXT PRIMARY KEY,-- name TEXT,-- upstream_status TEXT, -- Sources' health status, NULL = never reported-- last_success_at INTEGER, -- last successful fetch (Sources, or the newest retrieval Trade recorded)-- stale_after_sec INTEGER,-- reported_at INTEGER, -- when Sources last told us about it-- stale INTEGER NOT NULL DEFAULT 1, -- as of the last evaluation-- stale_since INTEGER,-- evaluated_at INTEGER,-- terms_note TEXT,-- license_note TEXT--);----CREATE TABLE IF NOT EXISTS trade_sync_state (-- name TEXT PRIMARY KEY,-- cursor INTEGER NOT NULL DEFAULT 0,-- last_ok_at INTEGER,-- last_error TEXT,-- last_error_at INTEGER,-- counts TEXT NOT NULL DEFAULT '{}'--);--`;++/**++ * 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.++ */++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 trade)');++ log.warn(`[Trade] 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: 'trade', 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: 'trade-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'trade', registry, log });++}/**-- * Open (or create) the database and the package stores on it.-- * opts.now — injectable clock (epoch ms) shared by everything, so tests are deterministic.++ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms), so tests and replays are++ * deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).*/--function openStore(dbPath, { now = () => Date.now() } = {}) {-- 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);++function createStore(db, { now = () => Date.now() } = {}) {return {db,now,revisions: createRevisionStore(db, { prefix: 'trade_context', now }),reviews: createReviewLog(db, { prefix: 'trade_context', now }),sequencer: createIndexSequencer(db, { prefix: 'trade', now }),-- tx: (fn) => db.transaction(fn)(),++ tx: async (fn) => await db.tx(() => fn()),close: () => db.close(),};}++/** openDb + createStore. */++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++const CHARTER_TABLES = ['trade_instruments', 'trade_instrument_aliases', 'trade_watchlists', 'trade_watchlist_items','trade_market_observations', 'trade_source_documents', 'trade_alert_rules', 'trade_alert_deliveries', 'trade_context_revisions'];--module.exports = { openStore, CHARTER_TABLES };++module.exports = { openDb, openStore, createStore, MIGRATIONS, CHARTER_TABLES };@@@ -39,16 +39,16 @@ function createAlerts({ store, config, ctx }) {thresholds: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind = 'threshold' AND metric = ? AND unit = ?"),docRules: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind IN ('filing_type','new_document')"),setState: db.prepare('UPDATE trade_alert_rules SET armed = @armed, last_observed_at = @observed, updated_at = @now WHERE id = @id'),-- deliver: db.prepare(`INSERT OR IGNORE INTO trade_alert_deliveries (id, rule_id, owner_subject, trigger_kind, trigger_id, summary, created_at)-- VALUES (@id, @rule_id, @owner, @kind, @trigger_id, @summary, @now)`),++ deliver: db.prepare(`INSERT INTO trade_alert_deliveries (id, rule_id, owner_subject, trigger_kind, trigger_id, summary, created_at)++ VALUES (@id, @rule_id, @owner, @kind, @trigger_id, @summary, @now) ON CONFLICT DO NOTHING`),setEvent: db.prepare('UPDATE trade_alert_deliveries SET event_id = ? WHERE id = ?'),deliveries: db.prepare(`SELECT d.*, r.kind AS rule_kind FROM trade_alert_deliveries d JOIN trade_alert_rules r ON r.id = d.rule_idWHERE d.owner_subject = ? ORDER BY d.created_at DESC, d.id DESC LIMIT ?`),countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_alert_deliveries WHERE rule_id = ?'),};-- function ruleDto(r, instrument) {-- const ins = instrument || ctx.instruments.get(r.instrument_id);++ async function ruleDto(r, instrument) {++ const ins = instrument || await ctx.instruments.get(r.instrument_id);return {id: r.id, kind: r.kind, status: r.status,instrument: ins ? { id: ins.id, symbol: ins.symbol, name: ins.name } : { id: r.instrument_id },@@@ -59,47 +59,47 @@ function createAlerts({ store, config, ctx }) {};}-- function deliver(rule, instrument, kind, trigger, summary, { traceparent } = {}) {++ async function deliver(rule, instrument, kind, trigger, summary, { traceparent } = {}) {const id = newId('ald', store.now());-- const info = q.deliver.run({ id, rule_id: rule.id, owner: rule.owner_subject, kind, trigger_id: trigger.id, summary: JSON.stringify(summary), now: store.now() });++ const info = await q.deliver.run({ id, rule_id: rule.id, owner: rule.owner_subject, kind, trigger_id: trigger.id, summary: JSON.stringify(summary), now: store.now() });if (!info.changes) return null; // already delivered for this (rule, trigger)-- const env = ctx.outbox.emit({++ const env = await ctx.outbox.emit({event_type: 'trade.alert.triggered', actor: { type: 'service', id: 'trade' }, visibility: 'subject', priority: 'important',subject: { type: 'user', id: rule.owner_subject },payload: {delivery_id: id,-- rule: ruleDto(rule, instrument),++ rule: await ruleDto(rule, instrument),instrument: { id: instrument.id, symbol: instrument.symbol, name: instrument.name, url: ctx.urls.instrument(instrument) },trigger: { kind, id: trigger.id, ...summary },disclaimer: DISCLAIMER,},}, { traceparent });-- q.setEvent.run(env.event_id, id);++ await q.setEvent.run(env.event_id, id);return { id, event_id: env.event_id };}const api = {KINDS,DISCLAIMER,-- get: (id) => q.byId.get(id) || null,++ get: async (id) => await q.byId.get(id) || null,dto: ruleDto,-- forOwner(subject) { return q.forOwner.all(subject).map((r) => ruleDto(r, { id: r.instrument_id, symbol: r.symbol, name: r.instrument_name })); },++ async forOwner(subject) { return (await Promise.all((await q.forOwner.all(subject)).map(async (r) => await ruleDto(r, { id: r.instrument_id, symbol: r.symbol, name: r.instrument_name })))); },-- deliveries(subject, limit = 50) {-- return q.deliveries.all(subject, Math.min(Math.max(Number(limit) || 50, 1), 200)).map((d) => ({++ async deliveries(subject, limit = 50) {++ return (await q.deliveries.all(subject, Math.min(Math.max(Number(limit) || 50, 1), 200))).map((d) => ({id: d.id, rule_id: d.rule_id, rule_kind: d.rule_kind, trigger: { kind: d.trigger_kind, id: d.trigger_id, ...json(d.summary, {}) },event_id: d.event_id, created_at: iso(d.created_at),}));},-- deliveryCount: (ruleId) => q.countFor.get(ruleId).n,++ deliveryCount: async (ruleId) => (await q.countFor.get(ruleId)).n,-- create(subject, instrument, input) {++ async create(subject, instrument, input) {if (!subject) throw new ApiError(403, 'subject.required', 'Alert rules belong to a person');if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');const kind = String(input.kind || '');if (!KINDS.includes(kind)) throw invalid(`kind must be one of ${KINDS.join(', ')}`);-- if (q.countOwner.get(subject).n >= config.limits.alertRulesPerSubject) throw new ApiError(429, 'alert.limit', `At most ${config.limits.alertRulesPerSubject} alert rules`);++ if ((await q.countOwner.get(subject)).n >= config.limits.alertRulesPerSubject) throw new ApiError(429, 'alert.limit', `At most ${config.limits.alertRulesPerSubject} alert rules`);const row = { id: newId('alr', store.now()), owner: subject, instrument_id: instrument.id, kind, metric: null, operator: null, threshold: null, threshold_num: null, unit: null, currency: null, form_types: null, now: store.now() };if (kind === 'threshold') {row.metric = str(input.metric, 80, 'metric', { required: true });@@@ -118,29 +118,29 @@ function createAlerts({ store, config, ctx }) {if (!list.length || list.length > 10) throw invalid('form_types must list 1–10 form types, e.g. 10-K, 10-Q, 8-K');row.form_types = JSON.stringify(list);}-- q.insert.run(row);-- return q.byId.get(row.id);++ await q.insert.run(row);++ return await q.byId.get(row.id);},-- remove(subject, id) {-- const r = q.byId.get(id);++ async remove(subject, id) {++ const r = await q.byId.get(id);// Someone else's rule is "not found": its existence is not disclosed.if (!r || r.owner_subject !== subject || r.status !== 'active') throw new ApiError(404, 'alert.not_found', 'No such alert rule');-- q.remove.run(store.now(), id);++ await q.remove.run(store.now(), id);return true;},/** Inside the transaction that recorded `obs`. */-- onObservation(obs, instrument, opts = {}) {++ async onObservation(obs, instrument, opts = {}) {const out = [];-- for (const rule of q.thresholds.all(instrument.id, obs.metric, obs.unit)) {++ for (const rule of await q.thresholds.all(instrument.id, obs.metric, obs.unit)) {if (rule.currency && rule.currency !== obs.currency) continue;if (rule.created_at > obs.recorded_at) continue;if (rule.last_observed_at != null && obs.observed_at <= rule.last_observed_at) continue; // late arrivalconst meets = rule.operator === 'above' ? obs.value_num > rule.threshold_num : obs.value_num < rule.threshold_num;let armed = rule.armed;if (meets && armed) {-- const d = deliver(rule, instrument, 'observation', obs, {++ const d = await deliver(rule, instrument, 'observation', obs, {metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency,observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), source_key: obs.source_key, source_url: obs.source_url,}, opts);@@@ -149,20 +149,20 @@ function createAlerts({ store, config, ctx }) {} else if (!meets) {armed = 1;}-- q.setState.run({ id: rule.id, armed, observed: obs.observed_at, now: store.now() });++ await q.setState.run({ id: rule.id, armed, observed: obs.observed_at, now: store.now() });}return out;},/** Inside the transaction that first recorded `doc`. */-- onDocument(doc, instrument, opts = {}) {++ async onDocument(doc, instrument, opts = {}) {const out = [];-- for (const rule of q.docRules.all(instrument.id)) {++ for (const rule of await q.docRules.all(instrument.id)) {if (rule.kind === 'filing_type') {const types = json(rule.form_types, []);if (!doc.form_type || !types.includes(doc.form_type)) continue;}-- const d = deliver(rule, instrument, 'document', doc, {++ const d = await deliver(rule, instrument, 'document', doc, {form_type: doc.form_type, title: doc.title, url: doc.url, published_at: iso(doc.published_at),retrieved_at: iso(doc.retrieved_at), source_key: doc.source_key,}, opts);@@@ -38,7 +38,7 @@ function createContext({ store, config, ctx }) {}/** Validate cited refs: they must be this instrument's documents (not removed) or observations. */-- function resolveCites(instrument, cites) {++ async function resolveCites(instrument, cites) {if (cites == null) return [];if (!Array.isArray(cites) || cites.length > 50) throw invalid('cites must be a list of at most 50 { kind, id }');const seen = new Set();@@@ -47,10 +47,10 @@ function createContext({ store, config, ctx }) {const kind = c && c.kind;const id = c && String(c.id || '');if (kind === 'document') {-- const d = ctx.documents.get(id);++ const d = await ctx.documents.get(id);if (!d || d.instrument_id !== instrument.id || d.removed_at) throw invalid(`document ${id} is not a current document of ${instrument.symbol}`, 'context.bad_citation');} else if (kind === 'observation') {-- const o = ctx.observations.get(id);++ const o = await ctx.observations.get(id);if (!o || o.instrument_id !== instrument.id) throw invalid(`observation ${id} is not an observation of ${instrument.symbol}`, 'context.bad_citation');} else {throw invalid('each citation is { kind: "document" | "observation", id }', 'context.bad_citation');@@@ -61,12 +61,12 @@ function createContext({ store, config, ctx }) {return out;}-- function asOf(cites) {++ async function asOf(cites) {let t = null;for (const c of cites) {let v = null;-- if (c.kind === 'observation') { const o = ctx.observations.get(c.id); v = o && o.observed_at; }-- else { const d = ctx.documents.get(c.id); v = d && (d.published_at || d.retrieved_at); }++ if (c.kind === 'observation') { const o = await ctx.observations.get(c.id); v = o && o.observed_at; }++ else { const d = await ctx.documents.get(c.id); v = d && (d.published_at || d.retrieved_at); }if (v != null && (t == null || v > t)) t = v;}return iso(t);@@@ -78,15 +78,15 @@ function createContext({ store, config, ctx }) {return false;}-- function create(instrument, { content, fields, rec, author, message, expectedRevision }) {-- const head = store.revisions.headNumber(instrument.id);++ async function create(instrument, { content, fields, rec, author, message, expectedRevision }) {++ const head = await store.revisions.headNumber(instrument.id);const expected = expectedRevision == null ? head : Number(expectedRevision);if (!Number.isInteger(expected) || expected < 0) throw invalid('expected_revision must be a revision number');-- return store.revisions.create({ entityId: instrument.id, expectedRevision: expected, content, fields, meta: { authorship: rec }, author, message, allowUnchanged: true }).revision;++ return (await store.revisions.create({ entityId: instrument.id, expectedRevision: expected, content, fields, meta: { authorship: rec }, author, message, allowUnchanged: true })).revision;}/** AI output → markdown. Citations are indices into input_sources (what the AI was given). */-- function fromAiOutput(instrument, body) {++ async function fromAiOutput(instrument, body) {const wf = body.workflow || {};if (wf.id !== WORKFLOW) throw invalid(`AI context must come from the ${WORKFLOW} workflow`, 'context.wrong_workflow');const runId = wf.run_id || wf.runId;@@@ -131,17 +131,17 @@ function createContext({ store, config, ctx }) {stubProvider: body.stub_provider === true,source: { label: `${instrument.symbol} documents and observations on OpenVibe.Trade` },});-- resolveCites(instrument, refs); // every source the model was given must exist-- return { content: lines.join('\n'), cites: resolveCites(instrument, all), rec, gaps };++ await resolveCites(instrument, refs); // every source the model was given must exist++ return { content: lines.join('\n'), cites: await resolveCites(instrument, all), rec, gaps };}const api = {WORKFLOW,isEditorSubject,-- head: (instrument) => store.revisions.head(instrument.id),-- revision: (instrument, n) => store.revisions.get(instrument.id, n),-- revisions: (instrument, opts) => store.revisions.list(instrument.id, opts),++ head: async (instrument) => await store.revisions.head(instrument.id),++ revision: async (instrument, n) => await store.revisions.get(instrument.id, n),++ revisions: async (instrument, opts) => await store.revisions.list(instrument.id, opts),/*** A new context revision. Editors: { body, cites?, expected_revision?, publish? }.@@@ -149,13 +149,13 @@ function createContext({ store, config, ctx }) {* input_sources: [{ source_type, source_id }], output: { summary, observations, gaps, citations },* stub_provider? } — always a draft.*/-- propose(viewer, instrument, body = {}) {++ async propose(viewer, instrument, body = {}) {if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');if (viewer.kind === 'service' && viewer.origin === 'ai') {-- const { content, cites, rec, gaps } = fromAiOutput(instrument, body);++ const { content, cites, rec, gaps } = await fromAiOutput(instrument, body);refuseAdvice(content);-- const fields = { as_of: asOf(cites), cites, origin: 'ai', gaps };-- const revision = store.tx(() => create(instrument, { content, fields, rec, author: viewer.service, message: 'AI draft (needs a person\'s review)' }));++ const fields = { as_of: await asOf(cites), cites, origin: 'ai', gaps };++ const revision = await store.tx(async () => await create(instrument, { content, fields, rec, author: viewer.service, message: 'AI draft (needs a person\'s review)' }));return { revision, published: false };}if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors write context');@@@ -163,13 +163,13 @@ function createContext({ store, config, ctx }) {if (!text) throw invalid('body is required');if (text.length > MAX_BODY) throw invalid(`body is longer than ${MAX_BODY} characters`);refuseAdvice(text);-- const cites = resolveCites(instrument, body.cites);++ const cites = await resolveCites(instrument, body.cites);const rec = authorship.record({ mode: 'human', authors: [viewer.subject] });-- const fields = { as_of: asOf(cites), cites, origin: 'editor' };-- return store.tx(() => {-- const revision = create(instrument, { content: text, fields, rec, author: viewer.subject, message: body.message ? String(body.message).slice(0, 300) : null, expectedRevision: body.expected_revision });++ const fields = { as_of: await asOf(cites), cites, origin: 'editor' };++ return await store.tx(async () => {++ const revision = await create(instrument, { content: text, fields, rec, author: viewer.subject, message: body.message ? String(body.message).slice(0, 300) : null, expectedRevision: body.expected_revision });if (body.publish === true || body.publish === 'on' || body.publish === '1') {-- api.publish(instrument, revision.number);++ await api.publish(instrument, revision.number);return { revision, published: true };}return { revision, published: false };@@@ -177,64 +177,64 @@ function createContext({ store, config, ctx }) {},/** A person's review. Approving an AI draft publishes it. */-- review(viewer, instrument, number, { decision, note } = {}) {++ async review(viewer, instrument, number, { decision, note } = {}) {if (viewer.kind !== 'user' || !viewer.editor || !viewer.subject) throw new ApiError(403, 'review.forbidden', 'Only a person on the editor list can review context');-- const rev = store.revisions.get(instrument.id, Number(number));++ const rev = await store.revisions.get(instrument.id, Number(number));if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');-- return store.tx(() => {-- const review = store.reviews.record({ entityId: instrument.id, revision: rev.number, reviewer: viewer.subject, decision, note });++ return await store.tx(async () => {++ const review = await store.reviews.record({ entityId: instrument.id, revision: rev.number, reviewer: viewer.subject, decision, note });let published = false;-- if (decision === 'approved') { api.publish(instrument, rev.number); published = true; }++ if (decision === 'approved') { await api.publish(instrument, rev.number); published = true; }return { review, published };});},-- publish(instrument, number) {-- const rev = store.revisions.get(instrument.id, Number(number));++ async publish(instrument, number) {++ const rev = await store.revisions.get(instrument.id, Number(number));if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');const rec = rev.meta.authorship;-- const ok = authorship.canPublish(rec, store.reviews.latest(instrument.id, rev.number));++ const ok = authorship.canPublish(rec, await store.reviews.latest(instrument.id, rev.number));if (!ok.ok) throw new ApiError(409, 'context.review_required', 'AI-generated context needs an approving review by a person before it is published');-- return store.tx(() => {-- setPublished.run(rev.number, store.now(), store.now(), instrument.id);-- ctx.indexing.refresh(ctx.instruments.get(instrument.id));++ return await store.tx(async () => {++ await setPublished.run(rev.number, store.now(), store.now(), instrument.id);++ await ctx.indexing.refresh(await ctx.instruments.get(instrument.id));return rev;});},-- retract(viewer, instrument) {++ async retract(viewer, instrument) {if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors retract context');-- return store.tx(() => {-- setPublished.run(null, null, store.now(), instrument.id);-- ctx.indexing.refresh(ctx.instruments.get(instrument.id));++ return await store.tx(async () => {++ await setPublished.run(null, null, store.now(), instrument.id);++ await ctx.indexing.refresh(await ctx.instruments.get(instrument.id));return true;});},/** The published revision's view, or null. */-- published(instrument) {++ async published(instrument) {if (!instrument.context_published_revision) return null;-- const rev = store.revisions.get(instrument.id, instrument.context_published_revision);-- return rev ? api.view(instrument, rev) : null;++ const rev = await store.revisions.get(instrument.id, instrument.context_published_revision);++ return rev ? await api.view(instrument, rev) : null;},/** Revisions after the published one (drafts awaiting review or publication). */-- pending(instrument) {++ async pending(instrument) {const published = instrument.context_published_revision || 0;-- return store.revisions.list(instrument.id, { limit: 20 }).filter((r) => r.number > published).map((r) => api.view(instrument, r));++ return (await Promise.all((await store.revisions.list(instrument.id, { limit: 20 })).filter((r) => r.number > published).map(async (r) => await api.view(instrument, r))));},-- view(instrument, rev) {++ async view(instrument, rev) {const rec = rev.meta.authorship || null;-- const review = store.reviews.latest(instrument.id, rev.number);-- const cites = (rev.fields.cites || []).map((c) => {++ const review = await store.reviews.latest(instrument.id, rev.number);++ const cites = (await Promise.all((rev.fields.cites || []).map(async (c) => {if (c.kind === 'observation') {-- const o = ctx.observations.get(c.id);-- return o ? { kind: c.kind, id: c.id, observation: ctx.observations.dto(o) } : { kind: c.kind, id: c.id, missing: true };++ const o = await ctx.observations.get(c.id);++ return o ? { kind: c.kind, id: c.id, observation: await ctx.observations.dto(o) } : { kind: c.kind, id: c.id, missing: true };}-- const d = ctx.documents.get(c.id);++ const d = await ctx.documents.get(c.id);return d && !d.removed_at ? { kind: c.kind, id: c.id, document: ctx.documents.dto(d) } : { kind: c.kind, id: c.id, missing: true };-- });++ })));return {revision: rev.number,published: instrument.context_published_revision === rev.number,@@@ -254,10 +254,10 @@ function createContext({ store, config, ctx }) {},/** The input OpenVibe.AI's trade.summarize_market_context expects, numbered sources included. */-- input(instrument) {++ async input(instrument) {const sources = [];-- for (const o of ctx.observations.latest(instrument)) {-- const dto = ctx.observations.dto(o);++ for (const o of await ctx.observations.latest(instrument)) {++ const dto = await ctx.observations.dto(o);sources.push({source_type: 'trade.observation', source_id: o.id, url: o.source_url || undefined,title: `${o.metric} of ${instrument.symbol}`,@@@ -266,8 +266,8 @@ function createContext({ store, config, ctx }) {provenance: { source_key: o.source_key, stale: dto.freshness.stale, stale_since: dto.freshness.stale_since },});}-- for (const d of ctx.documents.forInstrument(instrument, { limit: 20 })) {-- const fresh = ctx.freshness.view(d.source_key);++ for (const d of await ctx.documents.forInstrument(instrument, { limit: 20 })) {++ const fresh = await ctx.freshness.view(d.source_key);sources.push({source_type: 'trade.document', source_id: d.id, url: d.url || undefined, title: d.title || undefined,published_at: iso(d.published_at) || undefined, retrieved_at: iso(d.retrieved_at),@@@ -276,7 +276,7 @@ function createContext({ store, config, ctx }) {});}const clean = sources.map((s) => JSON.parse(JSON.stringify(s)));-- const windows = [...new Set(clean.map((s) => ctx.freshness.view(s.provenance.source_key).stale_after_sec))];++ const windows = [...new Set((await Promise.all(clean.map(async (s) => (await ctx.freshness.view(s.provenance.source_key)).stale_after_sec))))];const hours = Math.max(1, Math.ceil((windows.length ? Math.min(...windows) : config.freshness.defaultStaleAfterSec) / 3600));return {workflow: WORKFLOW,@@@ -33,40 +33,40 @@ function createDocuments({ store, ctx }) {};const api = {-- get: (id) => q.byId.get(id) || null,-- byItem: (itemId) => q.byItem.get(itemId) || null,-- forInstrument: (instrument, { limit = 50, offset = 0 } = {}) => q.forInstrument.all(instrument.id, limit, offset),-- count: (instrument) => q.countFor.get(instrument.id).n,-- recent: (limit = 50) => q.recent.all(limit),++ get: async (id) => await q.byId.get(id) || null,++ byItem: async (itemId) => await q.byItem.get(itemId) || null,++ forInstrument: async (instrument, { limit = 50, offset = 0 } = {}) => await q.forInstrument.all(instrument.id, limit, offset),++ count: async (instrument) => (await q.countFor.get(instrument.id)).n,++ recent: async (limit = 50) => await q.recent.all(limit),/** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */-- upsert(doc, instrument, { traceparent } = {}) {-- return store.tx(() => {-- const existing = q.byItem.get(doc.source_item_id);++ async upsert(doc, instrument, { traceparent } = {}) {++ return await store.tx(async () => {++ const existing = await q.byItem.get(doc.source_item_id);if (existing) {if (existing.instrument_id !== instrument.id) return { document: existing, created: false, changed: false };if (doc.source_revision > existing.source_revision) {-- q.revise.run({ ...doc, id: existing.id, now: store.now() });++ await q.revise.run({ ...doc, id: existing.id, now: store.now() });} else {-- q.touch.run({ id: existing.id, retrieved_at: doc.retrieved_at, now: store.now() });++ await q.touch.run({ id: existing.id, retrieved_at: doc.retrieved_at, now: store.now() });}-- ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);-- return { document: q.byId.get(existing.id), created: false, changed: doc.source_revision > existing.source_revision };++ await ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);++ return { document: await q.byId.get(existing.id), created: false, changed: doc.source_revision > existing.source_revision };}const id = newId('doc', store.now());-- q.insert.run({ ...doc, id, instrument_id: instrument.id, now: store.now() });-- const row = q.byId.get(id);-- ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);-- ctx.alerts.onDocument(row, instrument, { traceparent });++ await q.insert.run({ ...doc, id, instrument_id: instrument.id, now: store.now() });++ const row = await q.byId.get(id);++ await ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);++ await ctx.alerts.onDocument(row, instrument, { traceparent });return { document: row, created: true, changed: true };});},/** Sources removed the item: hide the document (the row stays as the record). */-- remove(itemId, { at, reason }) {-- const row = q.byItem.get(itemId);++ async remove(itemId, { at, reason }) {++ const row = await q.byItem.get(itemId);if (!row) return false;-- return q.remove.run({ id: row.id, at: at || store.now(), reason: String(reason || 'removed at the source').slice(0, 500), now: store.now() }).changes > 0;++ return (await q.remove.run({ id: row.id, at: at || store.now(), reason: String(reason || 'removed at the source').slice(0, 500), now: store.now() })).changes > 0;},dto(d) {@@@ -25,9 +25,9 @@ function createFreshness({ store, config, outbox }) {const q = {get: db.prepare('SELECT * FROM trade_source_status WHERE source_key = ?'),all: db.prepare('SELECT * FROM trade_source_status ORDER BY source_key'),-- ensure: db.prepare(`INSERT OR IGNORE INTO trade_source_status (source_key, stale, stale_since, evaluated_at) VALUES (?, 1, ?, NULL)`),++ ensure: db.prepare(`INSERT INTO trade_source_status (source_key, stale, stale_since, evaluated_at) VALUES (?, 1, ?, NULL) ON CONFLICT DO NOTHING`),report: db.prepare(`UPDATE trade_source_status SET name = COALESCE(@name, name), upstream_status = @status,-- last_success_at = CASE WHEN @last IS NULL THEN last_success_at WHEN last_success_at IS NULL OR @last > last_success_at THEN @last ELSE last_success_at END,++ last_success_at = CASE WHEN @last::bigint IS NULL THEN last_success_at WHEN last_success_at IS NULL OR @last > last_success_at THEN @last ELSE last_success_at END,stale_after_sec = COALESCE(@window, stale_after_sec), reported_at = @now,terms_note = COALESCE(@terms, terms_note), license_note = COALESCE(@license, license_note)WHERE source_key = @key`),@@@ -44,8 +44,8 @@ function createFreshness({ store, config, outbox }) {return { known: true, stale: now > until, staleSince: now > until ? until : null, window, lastSuccessAt: row.last_success_at };}-- function view(key, now = store.now()) {-- const row = q.get.get(key) || null;++ async function view(key, now = store.now()) {++ const row = await q.get.get(key) || null;const v = verdict(row, now);return {key,@@@ -63,21 +63,21 @@ function createFreshness({ store, config, outbox }) {};}-- function ensure(key) { q.ensure.run(key, store.now()); }++ async function ensure(key) { await q.ensure.run(key, store.now()); }/** Emit on a transition only. Inside the caller's transaction (or its own). */-- function evaluate(key) {-- return store.tx(() => {-- const row = q.get.get(key);++ async function evaluate(key) {++ return await store.tx(async () => {++ const row = await q.get.get(key);if (!row) return null;const v = verdict(row, store.now());const first = row.evaluated_at == null;const was = Boolean(row.stale);const since = v.stale ? (v.staleSince != null ? v.staleSince : (row.stale_since || store.now())) : null;-- q.mark.run({ key, stale: v.stale ? 1 : 0, since, now: store.now() });++ await q.mark.run({ key, stale: v.stale ? 1 : 0, since, now: store.now() });// First sight: a fresh source is not a recovery; a stale one is reported once.if (first ? !v.stale : v.stale === was) return null;-- const envelope = outbox.emit({++ const envelope = await outbox.emit({event_type: v.stale ? 'trade.source.stale' : 'trade.source.recovered',actor: { type: 'service', id: 'trade' }, visibility: 'public', priority: 'low',subject: { type: 'source', id: key },@@@ -95,23 +95,23 @@ function createFreshness({ store, config, outbox }) {view,ensure,evaluate,-- evaluateAll() { return q.all.all().map((r) => evaluate(r.source_key)).filter(Boolean); },-- all(now = store.now()) { return q.all.all().map((r) => view(r.source_key, now)); },++ async evaluateAll() { return (await Promise.all((await q.all.all()).map(async (r) => await evaluate(r.source_key)))).filter(Boolean); },++ async all(now = store.now()) { return (await Promise.all((await q.all.all()).map(async (r) => await view(r.source_key, now)))); },/** What Sources says about a source (its health block). Evaluates the transition. */-- report(key, { name = null, status = null, lastSuccessAt = null, staleAfterSec = null, termsNote = null, licenseNote = null } = {}) {-- return store.tx(() => {-- ensure(key);-- q.report.run({ key, name, status, last: lastSuccessAt, window: staleAfterSec, now: store.now(), terms: termsNote, license: licenseNote });-- return evaluate(key);++ async report(key, { name = null, status = null, lastSuccessAt = null, staleAfterSec = null, termsNote = null, licenseNote = null } = {}) {++ return await store.tx(async () => {++ await ensure(key);++ await q.report.run({ key, name, status, last: lastSuccessAt, window: staleAfterSec, now: store.now(), terms: termsNote, license: licenseNote });++ return await evaluate(key);});},/** A datum retrieved at `at` proves a successful fetch at that time. */-- noteRetrieval(key, at) {-- ensure(key);-- q.retrieval.run({ key, at });-- return evaluate(key);++ async noteRetrieval(key, at) {++ await ensure(key);++ await q.retrieval.run({ key, at });++ return await evaluate(key);},};}@@@ -30,15 +30,15 @@ function createIndexing({ store, config, ctx }) {const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULLORDER BY observed_at DESC LIMIT 1`);-- function publishedContext(instrument) {++ async function publishedContext(instrument) {if (!instrument.context_published_revision) return null;-- return store.revisions.get(instrument.id, instrument.context_published_revision);++ return await store.revisions.get(instrument.id, instrument.context_published_revision);}-- function decide(instrument, now = store.now()) {-- const rev = publishedContext(instrument);++ async function decide(instrument, now = store.now()) {++ const rev = await publishedContext(instrument);const rec = rev ? rev.meta.authorship : null;-- const review = rev ? store.reviews.latest(instrument.id, rev.number) : null;++ const review = rev ? await store.reviews.latest(instrument.id, rev.number) : null;const reviewedContext = Boolean(rec && authorship.canPublish(rec, review).ok);const facts = {state: instrument.status === 'active' ? 'published' : 'unpublished',@@@ -49,17 +49,17 @@ function createIndexing({ store, config, ctx }) {sensitiveReviewed: reviewedContext,};if (rec) Object.assign(facts, authorship.gateFacts(rec, review));-- const m = latestMonetary.get(instrument.id);++ const m = await latestMonetary.get(instrument.id);if (m) facts.price = { observedAt: m.observed_at };return seo.evaluate(facts, { policy: { minWords: config.gate.minWords, priceMaxAgeMs: config.freshness.priceMaxAgeMs }, now });}-- function document(instrument, decision) {-- const rev = publishedContext(instrument);++ async function document(instrument, decision) {++ const rev = await publishedContext(instrument);const identity = { owner: OWNER, type: 'instrument', id: instrument.id, revision: 0 };if (!decision.listable || instrument.status !== 'active') return hooks.tombstone(identity);const rec = rev ? rev.meta.authorship : null;-- const aliases = ctx.instruments.aliases(instrument).map((a) => a.value);++ const aliases = (await ctx.instruments.aliases(instrument)).map((a) => a.value);return hooks.buildIndexDocument({...identity,state: 'published',@@@ -79,22 +79,22 @@ function createIndexing({ store, config, ctx }) {}/** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */-- function refresh(instrument, { traceparent } = {}) {++ async function refresh(instrument, { traceparent } = {}) {if (!instrument) return null;-- const decision = decide(instrument);-- const doc = document(instrument, decision);-- const prev = store.sequencer.current(OWNER, 'instrument', instrument.id);++ const decision = await decide(instrument);++ const doc = await document(instrument, decision);++ const prev = await store.sequencer.current(OWNER, 'instrument', instrument.id);if (doc.deleted && prev == null) return null;-- const stamped = store.sequencer.stamp(doc);++ const stamped = await store.sequencer.stamp(store.db, doc);if (prev != null && stamped.revision === prev) return null;-- return ctx.outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });++ return await ctx.outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });}return {OWNER,decide,refresh,-- refreshAll: () => store.tx(() => ctx.instruments.active().map((i) => refresh(i)).filter(Boolean)),++ refreshAll: async () => await store.tx(async () => (await Promise.all((await ctx.instruments.active()).map(async (i) => await refresh(i)))).filter(Boolean)),};}@@@ -62,8 +62,8 @@ function createInstruments({ store }) {aliasesOf: db.prepare('SELECT kind, value, normalized, created_at FROM trade_instrument_aliases WHERE instrument_id = ? ORDER BY kind, normalized'),insert: db.prepare(`INSERT INTO trade_instruments (id, symbol, name, kind, exchange, cik, currency, status, created_by, created_at, updated_at)VALUES (@id, @symbol, @name, @kind, @exchange, @cik, @currency, 'active', @by, @now, @now)`),-- insertAlias: db.prepare(`INSERT OR IGNORE INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)-- VALUES (?, ?, ?, ?, ?, ?)`),++ insertAlias: db.prepare(`INSERT INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),page: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol LIMIT ? OFFSET ?"),active: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol"),@@@ -93,11 +93,13 @@ function createInstruments({ store }) {return out;}-- function conflictGuard(fn) {-- try { return fn(); } catch (err) {-- if (err && /UNIQUE constraint failed/.test(err.message)) {-- if (/trade_instruments\.symbol/.test(err.message)) throw new ApiError(409, 'instrument.symbol_taken', 'Another instrument has this symbol');-- if (/trade_instruments\.cik/.test(err.message)) throw new ApiError(409, 'instrument.cik_taken', 'Another instrument has this CIK');++ async function conflictGuard(fn) {++ try { return await fn(); } catch (err) {++ // PostgreSQL unique_violation (23505): err.table and err.detail ("Key (symbol)=(…) already exists.") name it.++ if (err && err.code === '23505') {++ const own = err.table === 'trade_instruments';++ if (own && /\(symbol\)/.test(err.detail || '')) throw new ApiError(409, 'instrument.symbol_taken', 'Another instrument has this symbol');++ if (own && /\(cik\)/.test(err.detail || '')) throw new ApiError(409, 'instrument.cik_taken', 'Another instrument has this CIK');throw new ApiError(409, 'alias.taken', 'This ticker or CIK already names another instrument');}throw err;@@@ -108,34 +110,34 @@ function createInstruments({ store }) {KINDS,normSymbol, normCik, normName,-- get: (id) => q.byId.get(id) || null,-- bySymbol: (symbol) => { const s = normSymbol(symbol); return s ? q.bySymbol.get(s) || null : null; },-- aliases: (instrument) => q.aliasesOf.all(instrument.id),-- active: () => q.active.all(),-- page({ limit = 50, offset = 0 } = {}) {-- return { total: q.count.get().n, instruments: q.page.all(limit, offset) };++ get: async (id) => await q.byId.get(id) || null,++ bySymbol: async (symbol) => { const s = normSymbol(symbol); return s ? await q.bySymbol.get(s) || null : null; },++ aliases: async (instrument) => await q.aliasesOf.all(instrument.id),++ active: async () => await q.active.all(),++ async page({ limit = 50, offset = 0 } = {}) {++ return { total: (await q.count.get()).n, instruments: await q.page.all(limit, offset) };},/** input: { symbol, name, kind?, exchange?, cik?, currency? } → the new row. Aliases for the symbol, CIK and name are added. */-- create(input, by) {++ async create(input, by) {const symbol = normSymbol(input.symbol);if (!symbol) throw invalid('symbol must be 1–16 characters of A–Z, 0–9, "." or "-"');const f = fields(input);const id = newId('ins', store.now());-- return conflictGuard(() => store.tx(() => {++ return await conflictGuard(async () => await store.tx(async () => {// A ticker alias of another instrument must not be shadowed by a new symbol.-- const holder = q.alias.all('ticker', symbol).find((i) => i.symbol !== symbol);++ const holder = (await q.alias.all('ticker', symbol)).find((i) => i.symbol !== symbol);if (holder) throw new ApiError(409, 'alias.taken', `${symbol} is already an alias of ${holder.symbol}`);-- q.insert.run({ id, symbol, ...f, by: by || null, now: store.now() });-- q.insertAlias.run(id, 'ticker', symbol, symbol, by || null, store.now());-- if (f.cik) q.insertAlias.run(id, 'cik', f.cik, f.cik, by || null, store.now());++ await q.insert.run({ id, symbol, ...f, by: by || null, now: store.now() });++ await q.insertAlias.run(id, 'ticker', symbol, symbol, by || null, store.now());++ if (f.cik) await q.insertAlias.run(id, 'cik', f.cik, f.cik, by || null, store.now());const n = normName(f.name);-- if (n) q.insertAlias.run(id, 'name', f.name, n, by || null, store.now());-- return q.byId.get(id);++ if (n) await q.insertAlias.run(id, 'name', f.name, n, by || null, store.now());++ return await q.byId.get(id);}));},-- update(instrument, input) {++ async update(instrument, input) {const f = fields(input, { partial: true });if (input.status !== undefined) {if (!['active', 'archived'].includes(input.status)) throw invalid('status must be active or archived');@@@ -143,28 +145,28 @@ function createInstruments({ store }) {}const keys = Object.keys(f);if (!keys.length) return instrument;-- return conflictGuard(() => store.tx(() => {-- db.prepare(`UPDATE trade_instruments SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...f, now: store.now(), id: instrument.id });-- if (f.cik) q.insertAlias.run(instrument.id, 'cik', f.cik, f.cik, null, store.now());-- if (f.name) { const n = normName(f.name); if (n) q.insertAlias.run(instrument.id, 'name', f.name, n, null, store.now()); }-- return q.byId.get(instrument.id);++ return await conflictGuard(async () => await store.tx(async () => {++ await db.prepare(`UPDATE trade_instruments SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...f, now: store.now(), id: instrument.id });++ if (f.cik) await q.insertAlias.run(instrument.id, 'cik', f.cik, f.cik, null, store.now());++ if (f.name) { const n = normName(f.name); if (n) await q.insertAlias.run(instrument.id, 'name', f.name, n, null, store.now()); }++ return await q.byId.get(instrument.id);}));},-- addAlias(instrument, kind, value, by) {++ async addAlias(instrument, kind, value, by) {if (!NORMALIZE[kind]) throw invalid('alias kind must be ticker, cik or name');const raw = str(value, 200, 'alias', { required: true });const n = NORMALIZE[kind](raw);if (!n) throw invalid(`not a valid ${kind}`);if (kind === 'ticker') {-- const owner = q.bySymbol.get(n);++ const owner = await q.bySymbol.get(n);if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the symbol of ${owner.symbol}`);}if (kind === 'cik') {-- const owner = q.byCik.get(n);++ const owner = await q.byCik.get(n);if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the CIK of ${owner.symbol}`);}-- conflictGuard(() => q.insertAlias.run(instrument.id, kind, raw, n, by || null, store.now()));++ await conflictGuard(async () => await q.insertAlias.run(instrument.id, kind, raw, n, by || null, store.now()));return { kind, value: raw, normalized: n };},@@@ -172,7 +174,7 @@ function createInstruments({ store }) {* → { query, status: 'resolved'|'ambiguous'|'not_found', match: { kind, value } | null,* instrument | null, candidates: [] }*/-- resolve(query, { kind = null } = {}) {++ async resolve(query, { kind = null } = {}) {const raw = String(query == null ? '' : query).trim().slice(0, 200);const out = (status, match, instrument, candidates = []) => ({ query: raw, status, match, instrument: instrument || null, candidates });if (!raw) return out('not_found', null, null);@@@ -181,23 +183,23 @@ function createInstruments({ store }) {if (!kind || kind === 'cik') {const cik = /^(CIK)?\d{1,10}$/i.test(raw) ? normCik(raw) : null;if (cik) {-- const hit = q.byCik.get(cik) || q.alias.all('cik', cik)[0];++ const hit = await q.byCik.get(cik) || (await q.alias.all('cik', cik))[0];if (hit) return out('resolved', { kind: 'cik', value: cik }, hit);}}if (!kind || kind === 'ticker') {const sym = normSymbol(raw);if (sym) {-- const hit = q.bySymbol.get(sym);++ const hit = await q.bySymbol.get(sym);if (hit) return out('resolved', { kind: 'symbol', value: sym }, hit);-- const alias = q.alias.all('ticker', sym)[0];++ const alias = (await q.alias.all('ticker', sym))[0];if (alias) return out('resolved', { kind: 'ticker', value: sym }, alias);}}if (!kind || kind === 'name') {const n = normName(raw);if (n) {-- const hits = q.alias.all('name', n);++ const hits = await q.alias.all('name', n);if (hits.length === 1) return out('resolved', { kind: 'name', value: n }, hits[0]);if (hits.length > 1) return out('ambiguous', { kind: 'name', value: n }, null, hits);}@@@ -35,10 +35,10 @@ function createObservations({ store, ctx }) {VALUES (@id, @instrument_id, @metric, @value, @value_num, @unit, @currency, @period, @observed_at,@source_key, @source_item_id, @source_url, @source_ref, @retrieved_at, @max_age_sec, @recorded_by, @recorded_at)`),latest: db.prepare(`SELECT * FROM (-- SELECT o.*, ROW_NUMBER() OVER (PARTITION BY metric, unit, IFNULL(currency, '') ORDER BY observed_at DESC, recorded_at DESC, id DESC) AS rn-- FROM trade_market_observations o WHERE instrument_id = ?)++ SELECT o.*, ROW_NUMBER() OVER (PARTITION BY metric, unit, COALESCE(currency, '') ORDER BY observed_at DESC, recorded_at DESC, id DESC) AS rn++ FROM trade_market_observations o WHERE instrument_id = ?) latestWHERE rn = 1 ORDER BY metric, unit`),-- history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ? AND (@metric IS NULL OR metric = @metric)++ history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = @instrument AND (@metric::text IS NULL OR metric = @metric)AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),};@@@ -79,48 +79,48 @@ function createObservations({ store, ctx }) {&& (a.currency || null) === (b.currency || null) && a.observed_at === b.observed_at;const api = {-- get: (id) => q.byId.get(id) || null,-- count: (instrument) => q.count.get(instrument.id).n,-- latest: (instrument) => q.latest.all(instrument.id),-- history(instrument, { metric = null, before = null, limit = 50 } = {}) {++ get: async (id) => await q.byId.get(id) || null,++ count: async (instrument) => (await q.count.get(instrument.id)).n,++ latest: async (instrument) => await q.latest.all(instrument.id),++ async history(instrument, { metric = null, before = null, limit = 50 } = {}) {const b = parseTime(before);-- return q.history.all(instrument.id, { metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });++ return await q.history.all({ instrument: instrument.id, metric: metric == null ? null : metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });},/*** → { observation, created }. recordedBy: 'svc:…' or 'usr_…' (who recorded it here).*/-- record(input, instrument, { recordedBy, traceparent } = {}) {++ async record(input, instrument, { recordedBy, traceparent } = {}) {if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');if (!recordedBy) throw new TypeError('recordedBy is required');const row = validate(input || {}, instrument);-- return store.tx(() => {-- const existing = q.byRef.get(row.source_key, row.source_ref);++ return await store.tx(async () => {++ const existing = await q.byRef.get(row.source_key, row.source_ref);if (existing) {if (same(existing, row)) return { observation: existing, created: false };throw new ApiError(409, 'observation.conflict', 'This source reference already recorded a different value; a correction needs its own source_ref');}const id = newId('obs', store.now());-- q.insert.run({ id, ...row, recorded_by: recordedBy, recorded_at: store.now() });-- const obs = q.byId.get(id);-- ctx.outbox.emit({++ await q.insert.run({ id, ...row, recorded_by: recordedBy, recorded_at: store.now() });++ const obs = await q.byId.get(id);++ await ctx.outbox.emit({event_type: 'trade.observation.created', actor: actorOf(recordedBy), visibility: 'public', priority: 'low',subject: { type: 'observation', id },payload: {instrument: { id: instrument.id, symbol: instrument.symbol },-- ...api.dto(obs, { withFreshness: false }),++ ...await api.dto(obs, { withFreshness: false }),},}, { traceparent });-- ctx.freshness.noteRetrieval(obs.source_key, obs.retrieved_at);-- ctx.alerts.onObservation(obs, instrument, { traceparent });-- ctx.indexing.refresh(instrument, { traceparent });++ await ctx.freshness.noteRetrieval(obs.source_key, obs.retrieved_at);++ await ctx.alerts.onObservation(obs, instrument, { traceparent });++ await ctx.indexing.refresh(instrument, { traceparent });return { observation: obs, created: true };});},/** Is this observation stale at `now`? Its source's freshness, and its own max age. */-- staleness(obs, now = store.now()) {-- const src = ctx.freshness.view(obs.source_key, now);++ async staleness(obs, now = store.now()) {++ const src = await ctx.freshness.view(obs.source_key, now);const own = obs.max_age_sec ? obs.observed_at + obs.max_age_sec * 1000 : null;const ownStale = own != null && now > own;const candidates = [];@@@ -134,7 +134,7 @@ function createObservations({ store, ctx }) {};},-- dto(obs, { withFreshness = true, now = store.now() } = {}) {++ async dto(obs, { withFreshness = true, now = store.now() } = {}) {const out = {id: obs.id, metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency, period: obs.period,observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), recorded_at: iso(obs.recorded_at),@@@ -142,7 +142,7 @@ function createObservations({ store, ctx }) {source: { key: obs.source_key, item_id: obs.source_item_id, url: obs.source_url, ref: obs.source_ref },};if (withFreshness) {-- const s = api.staleness(obs, now);++ const s = await api.staleness(obs, now);out.freshness = { stale: s.stale, stale_since: s.stale_since, reason: s.reason, source_status: s.source.status, source_last_success_at: s.source.last_success_at };}return out;@@@ -17,26 +17,26 @@ function createReading({ store, ctx }) {}/** Everything the instrument page shows. docsPage: { limit, offset }. */-- function instrument(i, { limit = 25, offset = 0 } = {}) {++ async function instrument(i, { limit = 25, offset = 0 } = {}) {const now = store.now();-- const observations = ctx.observations.latest(i).map((o) => ctx.observations.dto(o, { now }));-- const docs = ctx.documents.forInstrument(i, { limit, offset }).map((d) => {++ const observations = (await Promise.all((await ctx.observations.latest(i)).map(async (o) => await ctx.observations.dto(o, { now }))));++ const docs = (await Promise.all((await ctx.documents.forInstrument(i, { limit, offset })).map(async (d) => {const dto = ctx.documents.dto(d);-- const f = ctx.freshness.view(d.source_key, now);++ const f = await ctx.freshness.view(d.source_key, now);dto.freshness = { stale: f.stale, stale_since: f.stale_since, reason: !f.known ? 'source_unknown' : f.stale ? 'source_stale' : null };return dto;-- });++ })));const keys = [...new Set([...observations.map((o) => o.source.key), ...docs.map((d) => d.source.key)])];-- const sources = keys.sort().map((k) => ctx.freshness.view(k, now));-- const decision = ctx.indexing.decide(i, now);++ const sources = (await Promise.all(keys.sort().map(async (k) => await ctx.freshness.view(k, now))));++ const decision = await ctx.indexing.decide(i, now);return {instrument: instrumentDto(i),-- aliases: ctx.instruments.aliases(i).map((a) => ({ kind: a.kind, value: a.value, normalized: a.normalized })),++ aliases: (await ctx.instruments.aliases(i)).map((a) => ({ kind: a.kind, value: a.value, normalized: a.normalized })),observations,documents: docs,-- documents_total: ctx.documents.count(i),++ documents_total: await ctx.documents.count(i),sources,-- context: ctx.context.published(i),++ context: await ctx.context.published(i),indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.reasons },disclaimer: DISCLAIMER,generated_at: iso(now),@@@ -25,17 +25,18 @@ function createSync({ store, config, ctx, sources, log = console }) {const { db } = store;const q = {get: db.prepare('SELECT * FROM trade_sync_state WHERE name = ?'),-- ensure: db.prepare("INSERT OR IGNORE INTO trade_sync_state (name, cursor, counts) VALUES (?, 0, '{}')"),++ ensure: db.prepare("INSERT INTO trade_sync_state (name, cursor, counts) VALUES (?, 0, '{}') ON CONFLICT DO NOTHING"),advance: db.prepare('UPDATE trade_sync_state SET cursor = @cursor, counts = @counts WHERE name = @name'),ok: db.prepare('UPDATE trade_sync_state SET last_ok_at = ?, last_error = NULL WHERE name = ?'),fail: db.prepare('UPDATE trade_sync_state SET last_error = ?, last_error_at = ? WHERE name = ?'),};-- q.ensure.run(CURSOR);++ let ensured = false; // the cursor row, written on the first run (not at construction: the factory stays synchronous)++ const ensureCursor = async () => { if (!ensured) { await q.ensure.run(CURSOR); ensured = true; } };let running = null;-- function reportHealth(key, h, extra = {}) {++ async function reportHealth(key, h, extra = {}) {if (!h || typeof h !== 'object') return;-- ctx.freshness.report(key, {++ await ctx.freshness.report(key, {name: extra.name || null,status: h.status || null,lastSuccessAt: parseTime(h.last_success_at),@@@ -45,70 +46,71 @@ function createSync({ store, config, ctx, sources, log = console }) {});}-- function resolveKeys(keys) {++ async function resolveKeys(keys) {if (keys.cik) {-- const r = ctx.instruments.resolve(keys.cik, { kind: 'cik' });++ const r = await ctx.instruments.resolve(keys.cik, { kind: 'cik' });if (r.status === 'resolved') return r.instrument;}if (keys.symbol) {-- const r = ctx.instruments.resolve(keys.symbol, { kind: 'ticker' });++ const r = await ctx.instruments.resolve(keys.symbol, { kind: 'ticker' });if (r.status === 'resolved') return r.instrument;}return null;}/** Apply one item. → a counter name. Inside the page transaction. */-- function applyItem(item) {-- if (item.removed) return ctx.documents.remove(item.id, { at: parseTime(item.removed.at), reason: item.removed.reason }) ? 'documents_removed' : 'removed_unknown';++ async function applyItem(item) {++ if (item.removed) return await ctx.documents.remove(item.id, { at: parseTime(item.removed.at), reason: item.removed.reason }) ? 'documents_removed' : 'removed_unknown';const m = mapItem(item);if (m.type === 'skip') return `skipped_${m.reason}`;-- const instrument = resolveKeys(m.keys);++ const instrument = await resolveKeys(m.keys);if (!instrument) return 'skipped_no_matching_instrument';if (instrument.status !== 'active') return 'skipped_archived_instrument';if (m.type === 'observation') {try {-- const r = ctx.observations.record(m.observation, instrument, { recordedBy: 'svc:trade' });++ const r = await ctx.observations.record(m.observation, instrument, { recordedBy: 'svc:trade' });return r.created ? 'observations_created' : 'observations_unchanged';} catch (err) {if (err && (err.status === 409 || err.status === 422)) return `skipped_${String(err.code || 'invalid').replace(/\W+/g, '_')}`;throw err;}}-- const r = ctx.documents.upsert(m.document, instrument);++ const r = await ctx.documents.upsert(m.document, instrument);return r.created ? 'documents_created' : r.changed ? 'documents_updated' : 'documents_unchanged';}async function runOnce() {if (!sources.enabled) return { ok: false, skipped: 'disabled' };++ await ensureCursor();const summary = { pages: 0, counts: {} };try {const list = await sources.sources();for (const s of (list.sources || [])) {-- if (s && s.category === config.sources.category && s.key) reportHealth(s.key, s.health, s);++ if (s && s.category === config.sources.category && s.key) await reportHealth(s.key, s.health, s);}for (let page = 0; page < config.sources.maxPagesPerRun; page++) {-- const state = q.get.get(CURSOR);++ const state = await q.get.get(CURSOR);const data = await sources.items({ after: state.cursor, limit: config.sources.pageLimit });const items = Array.isArray(data.items) ? data.items : [];-- store.tx(() => {-- const counts = json(q.get.get(CURSOR).counts, {});++ await store.tx(async () => {++ const counts = json((await q.get.get(CURSOR)).counts, {});for (const item of items) {-- const k = applyItem(item);++ const k = await applyItem(item);counts[k] = (counts[k] || 0) + 1;summary.counts[k] = (summary.counts[k] || 0) + 1;}-- for (const [key, h] of Object.entries(data.sources || {})) reportHealth(key, h);++ for (const [key, h] of Object.entries(data.sources || {})) await reportHealth(key, h);const next = Number.isInteger(data.next_after) ? data.next_after : state.cursor;-- q.advance.run({ name: CURSOR, cursor: Math.max(next, state.cursor), counts: JSON.stringify(counts) });++ await q.advance.run({ name: CURSOR, cursor: Math.max(next, state.cursor), counts: JSON.stringify(counts) });});summary.pages++;if (!data.more || !items.length) break;}-- q.ok.run(store.now(), CURSOR);++ await q.ok.run(store.now(), CURSOR);ctx.outbox.kick();return { ok: true, ...summary };} catch (err) {-- q.fail.run(String(err.message).slice(0, 500), store.now(), CURSOR);++ await q.fail.run(String(err.message).slice(0, 500), store.now(), CURSOR);log.warn('[Trade] Sources sync failed:', err.message);return { ok: false, error: err.message, ...summary };}@@@ -123,8 +125,9 @@ function createSync({ store, config, ctx, sources, log = console }) {if (!running) running = runOnce().finally(() => { running = null; });return running;},-- state() {-- const s = q.get.get(CURSOR);++ async state() {++ await ensureCursor();++ const s = await q.get.get(CURSOR);return { enabled: sources.enabled, cursor: s.cursor, last_ok_at: iso(s.last_ok_at), last_error: s.last_error, last_error_at: iso(s.last_error_at), counts: json(s.counts, {}) };},};@@@ -21,64 +21,65 @@ function createWatchlists({ store, config, ctx }) {items: db.prepare(`SELECT w.*, i.symbol, i.name, i.kind, i.exchange, i.status FROM trade_watchlist_items wJOIN trade_instruments i ON i.id = w.instrument_id WHERE w.watchlist_id = ? ORDER BY i.symbol`),itemCount: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?'),-- addItem: db.prepare('INSERT OR IGNORE INTO trade_watchlist_items (watchlist_id, instrument_id, note, added_at) VALUES (?, ?, ?, ?)'),++ addItem: db.prepare('INSERT INTO trade_watchlist_items (watchlist_id, instrument_id, note, added_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING'),removeItem: db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ? AND instrument_id = ?'),touch: db.prepare('UPDATE trade_watchlists SET updated_at = ? WHERE id = ?'),};-- const unique = (fn) => {-- try { return fn(); } catch (err) {-- if (err && /UNIQUE constraint failed: trade_watchlists/.test(err.message)) throw new ApiError(409, 'watchlist.name_taken', 'You already have a watchlist with this name');++ const unique = async (fn) => {++ try { return await fn(); } catch (err) {++ // PostgreSQL unique_violation (23505) on trade_watchlists: the person's names are unique.++ if (err && err.code === '23505' && err.table === 'trade_watchlists') throw new ApiError(409, 'watchlist.name_taken', 'You already have a watchlist with this name');throw err;}};const api = {/** The owner's watchlist, or 404 for anyone else (existence is not disclosed). */-- mustOwn(subject, id) {-- const w = q.byId.get(String(id || ''));++ async mustOwn(subject, id) {++ const w = await q.byId.get(String(id || ''));if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');return w;},-- forOwner: (subject) => (subject ? q.forOwner.all(subject) : []),-- items: (w) => q.items.all(w.id),++ forOwner: async (subject) => (subject ? await q.forOwner.all(subject) : []),++ items: async (w) => await q.items.all(w.id),-- create(subject, input) {++ async create(subject, input) {if (!subject) throw new ApiError(403, 'subject.required', 'Watchlists belong to a person');const name = str(input.name, 80, 'name', { required: true });-- if (q.count.get(subject).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);++ if ((await q.count.get(subject)).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);const id = newId('wl', store.now());-- unique(() => q.insert.run(id, subject, name, store.now(), store.now()));-- return q.byId.get(id);++ await unique(async () => await q.insert.run(id, subject, name, store.now(), store.now()));++ return await q.byId.get(id);},-- rename(w, input) {++ async rename(w, input) {const name = str(input.name, 80, 'name', { required: true });-- unique(() => q.rename.run(name, store.now(), w.id));-- return q.byId.get(w.id);++ await unique(async () => await q.rename.run(name, store.now(), w.id));++ return await q.byId.get(w.id);},-- remove(w) { return store.tx(() => { db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return q.remove.run(w.id).changes > 0; }); },++ async remove(w) { return await store.tx(async () => { await db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return (await q.remove.run(w.id)).changes > 0; }); },-- add(w, instrument, note = null) {++ async add(w, instrument, note = null) {if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');-- if (q.itemCount.get(w.id).n >= config.limits.itemsPerWatchlist) throw new ApiError(429, 'watchlist.full', `At most ${config.limits.itemsPerWatchlist} instruments per watchlist`);-- const added = q.addItem.run(w.id, instrument.id, str(note, 300, 'note'), store.now()).changes > 0;-- if (added) q.touch.run(store.now(), w.id);++ if ((await q.itemCount.get(w.id)).n >= config.limits.itemsPerWatchlist) throw new ApiError(429, 'watchlist.full', `At most ${config.limits.itemsPerWatchlist} instruments per watchlist`);++ const added = (await q.addItem.run(w.id, instrument.id, str(note, 300, 'note'), store.now())).changes > 0;++ if (added) await q.touch.run(store.now(), w.id);return added;},-- drop(w, instrument) {++ async drop(w, instrument) {if (!instrument) return false;-- const removed = q.removeItem.run(w.id, instrument.id).changes > 0;-- if (removed) q.touch.run(store.now(), w.id);++ const removed = (await q.removeItem.run(w.id, instrument.id)).changes > 0;++ if (removed) await q.touch.run(store.now(), w.id);return removed;},-- dto(w, { withItems = true } = {}) {++ async dto(w, { withItems = true } = {}) {const out = { id: w.id, name: w.name, created_at: iso(w.created_at), updated_at: iso(w.updated_at) };if (withItems) {-- out.items = q.items.all(w.id).map((it) => ({++ out.items = (await q.items.all(w.id)).map((it) => ({instrument: { id: it.instrument_id, symbol: it.symbol, name: it.name, kind: it.kind, exchange: it.exchange, status: it.status, url: ctx.urls.instrument({ symbol: it.symbol }) },note: it.note, added_at: iso(it.added_at),}));@@@ -17,7 +17,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 createTradeOutbox({ db, config, fetchImpl, now, log = console }) {const enabled = Boolean(config.events.url && config.oauth.clientSecret);@@@ -33,7 +33,9 @@ function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {}const events = createEventsClient(createClient(clientOpts), { source: 'trade' });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,@@@ -43,11 +45,10 @@ function createTradeOutbox({ 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 });}return {@@@ -57,7 +58,7 @@ function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {start() { if (enabled) outbox.start(); },stop: () => outbox.stop(),kick() { if (enabled) outbox.kick(); },-- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),};}@@@ -11,15 +11,14 @@* reads the items themselves from the Sources API.*/const express = require('express');--const { parseDelivery, createInbox } = require('openvibe-sdk/events');++const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const { http } = require('openvibe-contracts');function createWebhook({ store, config, sync, log = console }) {const router = express.Router();-- const inbox = createInbox(store.db, { now: store.now });-- inbox.ensureSchema();++ const inbox = createPgInbox(store.db, { now: store.now }); // idempotency_receipts: migrations/0001_initial.sql-- router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), function receiveEventsDelivery(req, res) {++ router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), async function receiveEventsDelivery(req, res, next) {if (!config.events.webhookSecret) return http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');// Signature v2 only: HMAC over "<t>.<raw body>" with t within ±300 s; a v1-only (v2 stripped) or stale delivery is refused.@@@ -27,7 +26,8 @@ function createWebhook({ store, config, sync, log = console }) {if (!delivery) return http.sendProblem(res, 401, 'webhook.signature_invalid', { detail: 'The delivery signature does not verify', ctx: req.ov });const e = delivery.event;if (typeof e.event_id !== 'string' || typeof e.event_type !== 'string') return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'Not an event envelope', ctx: req.ov });-- const r = inbox.once('sources', e.event_id, () => true);++ let r;++ try { r = await inbox.once('sources', e.event_id, async () => true); } catch (err) { return next(err); }const wake = !r.duplicate && /^sources\.(item\.(created|updated|removed)|fetch\.failed)$/.test(e.event_type);if (wake) sync.run().catch((err) => log.warn('[Trade] sync after webhook failed:', err.message));res.status(200).json({ accepted: true, duplicate: r.duplicate, sync: wake });@@@ -24,7 +24,7 @@* Never limited: /api/health, /api/ready, /release.json, /release-metrics, /metrics, sign-in, the pages* and feeds people read, and the signed Events deliveries at /internal/events.*/--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');const FIRST_PARTY = /^svc:/;@@@ -75,7 +75,7 @@ const BUDGETS = {* limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on a counted* GET/HEAD) and limits.budget(name) (one of BUDGETS).*/--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {const refused = registry? registry.counter({ name: 'trade_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -83,6 +83,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lolimits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },actor,now,++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),onLimited(e) {// The actor is a subject id, a principal or an address, never a token.log.warn(`[Trade] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);@@@ -76,8 +76,8 @@ function createApi(ctx) {const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });-- function mustInstrument(req) {-- const i = instruments.bySymbol(req.params.symbol);++ async function mustInstrument(req) {++ const i = await instruments.bySymbol(req.params.symbol);if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');return i;}@@@ -103,14 +103,14 @@ function createApi(ctx) {// ── Instruments ─────────────────────────────────────────-- define(router, 'get', '/instruments', 'getInstruments', run((req) => {++ define(router, 'get', '/instruments', 'getInstruments', run(async (req) => {const { limit, offset } = paging(req);-- const page = instruments.page({ limit, offset });++ const page = await instruments.page({ limit, offset });return { total: page.total, instruments: page.instruments.map(reading.instrumentDto), disclaimer: DISCLAIMER };}));-- define(router, 'get', '/instruments/resolve', 'resolveInstrument', guard(C.INSTRUMENT_RESOLVE), run((req) => {-- const r = instruments.resolve(req.query.q, { kind: req.query.kind || null });++ define(router, 'get', '/instruments/resolve', 'resolveInstrument', guard(C.INSTRUMENT_RESOLVE), run(async (req) => {++ const r = await instruments.resolve(req.query.q, { kind: req.query.kind || null });return {query: r.query, status: r.status, match: r.match,instrument: r.instrument ? reading.instrumentDto(r.instrument) : null,@@@ -118,130 +118,130 @@ function createApi(ctx) {};}));-- define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {++ define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run(async (req) => {mustEditor(req);const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;-- const i = instruments.create(req.body || {}, by);-- store.tx(() => indexing.refresh(i, tp(req)));-- return { instrument: reading.instrumentDto(i), aliases: instruments.aliases(i) };++ const i = await instruments.create(req.body || {}, by);++ await store.tx(async () => await indexing.refresh(i, tp(req)));++ return { instrument: reading.instrumentDto(i), aliases: await instruments.aliases(i) };}, 201));-- define(router, 'get', '/instruments/:symbol', 'getInstrument', run((req) => {-- const { decision, ...data } = reading.instrument(mustInstrument(req), paging(req, 25, 100));++ define(router, 'get', '/instruments/:symbol', 'getInstrument', run(async (req) => {++ const { decision, ...data } = await reading.instrument(await mustInstrument(req), paging(req, 25, 100));return data;}));-- define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {++ define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run(async (req) => {mustEditor(req);-- const i = instruments.update(mustInstrument(req), req.body || {});-- store.tx(() => indexing.refresh(i, tp(req)));++ const i = await instruments.update(await mustInstrument(req), req.body || {});++ await store.tx(async () => await indexing.refresh(i, tp(req)));return { instrument: reading.instrumentDto(i) };}));-- define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {++ define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run(async (req) => {mustEditor(req);-- const i = mustInstrument(req);++ const i = await mustInstrument(req);const b = req.body || {};-- const alias = instruments.addAlias(i, String(b.kind || ''), b.value, req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject);-- return { alias, aliases: instruments.aliases(i) };++ const alias = await instruments.addAlias(i, String(b.kind || ''), b.value, req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject);++ return { alias, aliases: await instruments.aliases(i) };}, 201));-- define(router, 'get', '/instruments/:symbol/observations', 'getObservations', run((req) => {-- const i = mustInstrument(req);-- const list = observations.history(i, { metric: req.query.metric || null, before: req.query.before || null, limit: req.query.limit });-- return { instrument: reading.instrumentDto(i), observations: list.map((o) => observations.dto(o)), disclaimer: DISCLAIMER };++ define(router, 'get', '/instruments/:symbol/observations', 'getObservations', run(async (req) => {++ const i = await mustInstrument(req);++ const list = await observations.history(i, { metric: req.query.metric || null, before: req.query.before || null, limit: req.query.limit });++ return { instrument: reading.instrumentDto(i), observations: (await Promise.all(list.map(async (o) => await observations.dto(o)))), disclaimer: DISCLAIMER };}));-- define(router, 'get', '/instruments/:symbol/documents', 'getDocuments', run((req) => {-- const i = mustInstrument(req);-- return { instrument: reading.instrumentDto(i), total: documents.count(i), documents: documents.forInstrument(i, paging(req)).map(documents.dto) };++ define(router, 'get', '/instruments/:symbol/documents', 'getDocuments', run(async (req) => {++ const i = await mustInstrument(req);++ return { instrument: reading.instrumentDto(i), total: await documents.count(i), documents: (await documents.forInstrument(i, paging(req))).map(documents.dto) };}));// ── Observations (first-party feeds) ────────────────────-- define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), B('trade.observation.write'), jsonBody, run((req) => {++ define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), B('trade.observation.write'), jsonBody, run(async (req) => {if (req.viewer.kind !== 'service') throw new ApiError(403, 'observation.services_only', 'Observations come from sources, recorded by a service with trade.observation.write');const b = req.body || {};-- const instrument = b.instrument_id ? instruments.get(String(b.instrument_id)) : instruments.bySymbol(b.symbol);++ const instrument = b.instrument_id ? await instruments.get(String(b.instrument_id)) : await instruments.bySymbol(b.symbol);if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument (symbol or instrument_id)');-- const out = observations.record(b, instrument, { recordedBy: req.viewer.service, ...tp(req) });++ const out = await observations.record(b, instrument, { recordedBy: req.viewer.service, ...tp(req) });ctx.outbox.kick();-- return { observation: observations.dto(out.observation), created: out.created };++ return { observation: await observations.dto(out.observation), created: out.created };}, (out) => (out.created ? 201 : 200)));// ── Context ─────────────────────────────────────────────-- define(router, 'get', '/instruments/:symbol/context', 'getContext', guard(C.CONTEXT_READ), run((req) => {-- const i = mustInstrument(req);-- const out = { instrument: reading.instrumentDto(i), published: context.published(i), disclaimer: DISCLAIMER };++ define(router, 'get', '/instruments/:symbol/context', 'getContext', guard(C.CONTEXT_READ), run(async (req) => {++ const i = await mustInstrument(req);++ const out = { instrument: reading.instrumentDto(i), published: await context.published(i), disclaimer: DISCLAIMER };if (req.query.all === '1') {const v = req.viewer;if (!(v.kind === 'service' || (v.kind === 'user' && v.editor))) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors see drafts');-- out.pending = context.pending(i);++ out.pending = await context.pending(i);}return out;}));-- define(router, 'get', '/instruments/:symbol/context/input', 'getContextInput', guard(C.CONTEXT_READ), run((req) => context.input(mustInstrument(req))));++ define(router, 'get', '/instruments/:symbol/context/input', 'getContextInput', guard(C.CONTEXT_READ), run(async (req) => await context.input(await mustInstrument(req))));-- define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), B('trade.context.propose'), jsonBody, run((req) => {-- const i = mustInstrument(req);-- const out = context.propose(req.viewer, i, req.body || {});++ define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), B('trade.context.propose'), jsonBody, run(async (req) => {++ const i = await mustInstrument(req);++ const out = await context.propose(req.viewer, i, req.body || {});ctx.outbox.kick();-- return { revision: context.view(ctx.instruments.get(i.id), out.revision), published: out.published };++ return { revision: await context.view(await ctx.instruments.get(i.id), out.revision), published: out.published };}, 201));-- define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', B('trade.context.publish'), jsonBody, run((req) => {-- const i = mustInstrument(req);++ define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', B('trade.context.publish'), jsonBody, run(async (req) => {++ const i = await mustInstrument(req);const b = req.body || {};-- const out = context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });++ const out = await context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });ctx.outbox.kick();return out;}, 201));// ── Sources ─────────────────────────────────────────────-- define(router, 'get', '/sources', 'getSourceFreshness', run(() => ({ sources: freshness.all(), sync: ctx.sync.state() })));++ define(router, 'get', '/sources', 'getSourceFreshness', run(async () => ({ sources: await freshness.all(), sync: await ctx.sync.state() })));// ── Watchlists (private) ────────────────────────────────-- define(router, 'get', '/watchlists', 'getWatchlists', guard(C.WATCHLIST_READ), run((req) => ({ watchlists: watchlists.forOwner(owner(req)).map((w) => watchlists.dto(w)) })));++ define(router, 'get', '/watchlists', 'getWatchlists', guard(C.WATCHLIST_READ), run(async (req) => ({ watchlists: (await Promise.all((await watchlists.forOwner(owner(req))).map(async (w) => await watchlists.dto(w)))) })));-- define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run((req) => ({ watchlist: watchlists.dto(watchlists.create(owner(req), req.body || {})) }), 201));++ define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.create(owner(req), req.body || {})) }), 201));-- define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run((req) => ({ watchlist: watchlists.dto(watchlists.mustOwn(owner(req), req.params.id)) })));++ define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.mustOwn(owner(req), req.params.id)) })));-- define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run((req) => {-- const w = watchlists.mustOwn(owner(req), req.params.id);-- return { watchlist: watchlists.dto(watchlists.rename(w, req.body || {})) };++ define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run(async (req) => {++ const w = await watchlists.mustOwn(owner(req), req.params.id);++ return { watchlist: await watchlists.dto(await watchlists.rename(w, req.body || {})) };}));-- define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run((req) => {-- const w = watchlists.mustOwn(owner(req), req.params.id);-- const added = watchlists.add(w, instruments.bySymbol(req.params.symbol), (req.body || {}).note);-- return { added, watchlist: watchlists.dto(w) };++ define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run(async (req) => {++ const w = await watchlists.mustOwn(owner(req), req.params.id);++ const added = await watchlists.add(w, await instruments.bySymbol(req.params.symbol), (req.body || {}).note);++ return { added, watchlist: await watchlists.dto(w) };}));-- define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), run((req) => {-- const w = watchlists.mustOwn(owner(req), req.params.id);-- return { removed: watchlists.drop(w, instruments.bySymbol(req.params.symbol)), watchlist: watchlists.dto(w) };++ define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), run(async (req) => {++ const w = await watchlists.mustOwn(owner(req), req.params.id);++ return { removed: await watchlists.drop(w, await instruments.bySymbol(req.params.symbol)), watchlist: await watchlists.dto(w) };}));-- define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), B('trade.watchlist.update'), run((req) => ({ deleted: watchlists.remove(watchlists.mustOwn(owner(req), req.params.id)) })));++ define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), B('trade.watchlist.update'), run(async (req) => ({ deleted: await watchlists.remove(await watchlists.mustOwn(owner(req), req.params.id)) })));// ── Alerts (private) ────────────────────────────────────-- define(router, 'get', '/alerts', 'getAlertRules', guard(C.ALERT_READ), run((req) => ({ rules: alerts.forOwner(owner(req)) })));++ define(router, 'get', '/alerts', 'getAlertRules', guard(C.ALERT_READ), run(async (req) => ({ rules: await alerts.forOwner(owner(req)) })));-- define(router, 'get', '/alerts/deliveries', 'getAlertDeliveries', guard(C.ALERT_READ), run((req) => ({ deliveries: alerts.deliveries(owner(req), req.query.limit) })));++ define(router, 'get', '/alerts/deliveries', 'getAlertDeliveries', guard(C.ALERT_READ), run(async (req) => ({ deliveries: await alerts.deliveries(owner(req), req.query.limit) })));-- define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), B('trade.alert.create'), jsonBody, run((req) => {++ define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), B('trade.alert.create'), jsonBody, run(async (req) => {const b = req.body || {};-- const rule = alerts.create(owner(req), instruments.bySymbol(b.symbol), b);-- return { rule: alerts.dto(rule) };++ const rule = await alerts.create(owner(req), await instruments.bySymbol(b.symbol), b);++ return { rule: await alerts.dto(rule) };}, 201));-- define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), B('trade.alert.delete'), run((req) => ({ deleted: alerts.remove(owner(req), req.params.id) })));++ define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), B('trade.alert.delete'), run(async (req) => ({ deleted: await alerts.remove(owner(req), req.params.id) })));router.use(function apiNotFound(req, res) { contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }); });return router;@@@ -84,18 +84,20 @@ function createDiscovery(ctx) {}));});-- define(router, 'get', '/sitemap.xml', 'sitemapIndex', (_req, res) => {-- const times = instruments.active().filter((i) => indexing.decide(i).indexable && i.context_published_at).map((i) => i.context_published_at);++ define(router, 'get', '/sitemap.xml', 'sitemapIndex', async (_req, res) => {++ const active = await instruments.active();++ const decisions = await Promise.all(active.map(async (i) => await indexing.decide(i)));++ const times = active.filter((i, n) => decisions[n].indexable && i.context_published_at).map((i) => i.context_published_at);const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));});-- define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', (_req, res) => {-- const entries = instruments.active().map((i) => ({++ define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', async (_req, res) => {++ const entries = (await Promise.all((await instruments.active()).map(async (i) => ({loc: urls.instrument(i),...(i.context_published_at ? { lastmod: new Date(i.context_published_at).toISOString() } : {}),-- decision: indexing.decide(i),-- }));++ decision: await indexing.decide(i),++ }))));xml(res, seo.sitemap(entries).files[0]);});@@@ -106,17 +108,17 @@ function createDiscovery(ctx) {description: `Filings and documents recorded from OpenVibe.Sources, with their source dates. ${DISCLAIMER}`,language: 'en',});-- const recentItems = () => documents.recent(50).map((d) => feedItem(d, instruments.get(d.instrument_id)));-- define(router, 'get', '/feed.xml', 'documentsRss', (_req, res) => sendFeed(res, 'rss', siteChannel('rss'), recentItems()));-- define(router, 'get', '/atom.xml', 'documentsAtom', (_req, res) => sendFeed(res, 'atom', siteChannel('atom'), recentItems()));-- define(router, 'get', '/feed.json', 'documentsJsonFeed', (_req, res) => sendFeed(res, 'json', siteChannel('json'), recentItems()));++ const recentItems = async () => (await Promise.all((await documents.recent(50)).map(async (d) => feedItem(d, await instruments.get(d.instrument_id)))));++ define(router, 'get', '/feed.xml', 'documentsRss', async (_req, res) => sendFeed(res, 'rss', siteChannel('rss'), await recentItems()));++ define(router, 'get', '/atom.xml', 'documentsAtom', async (_req, res) => sendFeed(res, 'atom', siteChannel('atom'), await recentItems()));++ define(router, 'get', '/feed.json', 'documentsJsonFeed', async (_req, res) => sendFeed(res, 'json', siteChannel('json'), await recentItems()));-- define(router, 'get', '/i/:symbol/documents.:format', 'instrumentDocumentsFeed', (req, res) => {++ define(router, 'get', '/i/:symbol/documents.:format', 'instrumentDocumentsFeed', async (req, res) => {const type = { xml: 'rss', atom: 'atom', json: 'json' }[req.params.format];-- const instrument = instruments.bySymbol(req.params.symbol);++ const instrument = await instruments.bySymbol(req.params.symbol);if (!type || !instrument || instrument.status !== 'active') return common.notFound(req, res);if (instrument.symbol !== req.params.symbol) return res.redirect(301, urls.path.documentsFeed(instrument, type));-- const items = documents.forInstrument(instrument, { limit: 50 }).map((d) => feedItem(d, instrument));++ const items = (await documents.forInstrument(instrument, { limit: 50 })).map((d) => feedItem(d, instrument));sendFeed(res, type, {title: `${instrument.symbol} documents — OpenVibe.Trade`, link: urls.instrument(instrument),feedUrl: abs(urls.path.documentsFeed(instrument, type)),@@@ -36,8 +36,8 @@ function createEditor(ctx) {// does the same thing, checked once the session, form token and editor list are.const B = (name) => ctx.limits.budget(name);-- function mustInstrument(req) {-- const i = instruments.bySymbol(req.params.symbol);++ async function mustInstrument(req) {++ const i = await instruments.bySymbol(req.params.symbol);if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');return i;}@@@ -49,58 +49,58 @@ function createEditor(ctx) {common.after(res, path, { e: e.message });}-- define(router, 'get', '/editor', 'showEditor', requireEditor, (req, res) => {-- const all = instruments.active();++ define(router, 'get', '/editor', 'showEditor', requireEditor, async (req, res) => {++ const all = await instruments.active();const pending = [];-- for (const i of all) for (const p of context.pending(i)) pending.push({ symbol: i.symbol, revision: p.revision, label: p.needs_review ? 'AI draft, needs review' : 'not published' });++ for (const i of all) for (const p of await context.pending(i)) pending.push({ symbol: i.symbol, revision: p.revision, label: p.needs_review ? 'AI draft, needs review' : 'not published' });common.page(req, res, { title: 'Editor', decision, personal: true, body: views.editorHome({ instruments: all, pending, csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS }) });});-- define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, B('trade.instrument.manage'), (req, res) => {++ define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, B('trade.instrument.manage'), async (req, res) => {try {-- const i = instruments.create(req.body, req.viewer.subject);-- ctx.store.tx(() => indexing.refresh(i));++ const i = await instruments.create(req.body, req.viewer.subject);++ await ctx.store.tx(async () => await indexing.refresh(i));common.after(res, editPath(i), { n: 'instrument_created' });} catch (err) { fail(req, res, '/editor', err); }});-- define(router, 'get', '/editor/i/:symbol', 'showEditorInstrument', requireEditor, (req, res) => {++ define(router, 'get', '/editor/i/:symbol', 'showEditorInstrument', requireEditor, async (req, res) => {let i;-- try { i = mustInstrument(req); } catch (err) { return common.failure(req, res, err); }-- const revisions = context.revisions(i, { limit: 30 }).map((r) => context.view(i, r));++ try { i = await mustInstrument(req); } catch (err) { return common.failure(req, res, err); }++ const revisions = (await Promise.all((await context.revisions(i, { limit: 30 })).map(async (r) => await context.view(i, r))));common.page(req, res, {title: `Editor · ${i.symbol}`, decision, personal: true,body: views.editorInstrument({-- instrument: i, aliases: instruments.aliases(i), revisions, head: context.head(i) ? context.head(i).number : 0,-- documents: documents.forInstrument(i, { limit: 30 }), observations: observations.latest(i),++ instrument: i, aliases: await instruments.aliases(i), revisions, head: await context.head(i) ? (await context.head(i)).number : 0,++ documents: await documents.forInstrument(i, { limit: 30 }), observations: await observations.latest(i),csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS,}),});});-- define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, B('trade.instrument.manage'), (req, res) => {++ define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, B('trade.instrument.manage'), async (req, res) => {try {-- const i = mustInstrument(req);-- const updated = instruments.update(i, { name: req.body.name, kind: req.body.kind, exchange: req.body.exchange, cik: req.body.cik, currency: req.body.currency, status: req.body.status });-- ctx.store.tx(() => indexing.refresh(updated));++ const i = await mustInstrument(req);++ const updated = await instruments.update(i, { name: req.body.name, kind: req.body.kind, exchange: req.body.exchange, cik: req.body.cik, currency: req.body.currency, status: req.body.status });++ await ctx.store.tx(async () => await indexing.refresh(updated));common.after(res, editPath(updated), { n: 'instrument_saved' });} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }});-- define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, B('trade.instrument.manage'), (req, res) => {++ define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, B('trade.instrument.manage'), async (req, res) => {try {-- const i = mustInstrument(req);-- instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);++ const i = await mustInstrument(req);++ await instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);common.after(res, editPath(i), { n: 'alias_added' });} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }});-- define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, B('trade.context.propose'), (req, res) => {++ define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, B('trade.context.propose'), async (req, res) => {try {-- const i = mustInstrument(req);++ const i = await mustInstrument(req);const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];const cites = raw.map((s) => { const [kind, id] = String(s).split(':'); return { kind, id }; });-- const out = context.propose(req.viewer, i, { body: req.body.body, cites, expected_revision: req.body.expected_revision, publish: req.body.publish === '1' });++ const out = await context.propose(req.viewer, i, { body: req.body.body, cites, expected_revision: req.body.expected_revision, publish: req.body.publish === '1' });common.after(res, editPath(i), { n: out.published ? 'context_published' : 'context_saved' });} catch (err) {if (err && err.code === 'revision.conflict') return common.after(res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, { e: 'Someone saved a newer revision while you were writing. Your text was not saved: copy it, reload and try again.' });@@@ -108,27 +108,27 @@ function createEditor(ctx) {}});-- define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, B('trade.context.publish'), (req, res) => {++ define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, B('trade.context.publish'), async (req, res) => {try {-- const i = mustInstrument(req);++ const i = await mustInstrument(req);const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';-- context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: decisionValue, note: req.body.note || null });++ await context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: decisionValue, note: req.body.note || null });common.after(res, editPath(i), { n: decisionValue === 'approved' ? 'context_published' : 'context_rejected' });} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }});-- define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, B('trade.context.publish'), (req, res) => {++ define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, B('trade.context.publish'), async (req, res) => {try {-- const i = mustInstrument(req);-- context.publish(i, parseInt(req.params.n, 10));++ const i = await mustInstrument(req);++ await context.publish(i, parseInt(req.params.n, 10));common.after(res, editPath(i), { n: 'context_published' });} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }});-- define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, B('trade.context.publish'), (req, res) => {++ define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, B('trade.context.publish'), async (req, res) => {try {-- const i = mustInstrument(req);-- context.retract(req.viewer, i);++ const i = await mustInstrument(req);++ await context.retract(req.viewer, i);common.after(res, editPath(i), { n: 'context_retracted' });} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }});@@@ -30,25 +30,25 @@ function createPages(ctx) {title: 'What shipped on OpenVibe.Trade', canonical: urls.abs('/updates'), decision: listing('/updates'),body: frame.updatesBody({ service: 'trade', siteName: 'OpenVibe.Trade' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}));-- define(router, 'get', '/', 'showHome', (req, res) => {++ define(router, 'get', '/', 'showHome', async (req, res) => {const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);const per = 50;-- const first = instruments.page({ limit: 0, offset: 0 });++ const first = await instruments.page({ limit: 0, offset: 0 });const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });if (pager.outOfRange && first.total) return common.notFound(req, res);-- const page = instruments.page({ limit: pager.limit, offset: pager.offset });-- const recent = documents.recent(10).map((d) => ({ document: documents.dto(d), instrument: instruments.get(d.instrument_id) }));++ const page = await instruments.page({ limit: pager.limit, offset: pager.offset });++ const recent = (await Promise.all((await documents.recent(10)).map(async (d) => ({ document: documents.dto(d), instrument: await instruments.get(d.instrument_id) }))));const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);common.page(req, res, {title: null, canonical, decision: listing(pageNo === 1 ? '/' : `/?page=${pageNo}`),feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],-- body: views.home({ page, recent, urls, sync: sync.state(), pager }),++ body: views.home({ page, recent, urls, sync: await sync.state(), pager }),});});-- define(router, 'get', '/resolve', 'showResolve', (req, res) => {-- const result = instruments.resolve(req.query.q);++ define(router, 'get', '/resolve', 'showResolve', async (req, res) => {++ const result = await instruments.resolve(req.query.q);if (result.status === 'resolved') {common.vary(res);res.set('Cache-Control', 'public, max-age=60');@@@ -57,20 +57,20 @@ function createPages(ctx) {common.page(req, res, { status: result.status === 'not_found' ? 404 : 200, title: 'Find an instrument', body: views.resolvePage({ result, urls }), personal: true });});-- define(router, 'get', '/sources', 'showSources', (req, res) => {++ define(router, 'get', '/sources', 'showSources', async (req, res) => {common.page(req, res, {title: 'Source freshness', canonical: urls.abs('/sources'),-- body: views.sourcesPage({ sources: freshness.all(), sync: sync.state() }),++ body: views.sourcesPage({ sources: await freshness.all(), sync: await sync.state() }),});});-- define(router, 'get', '/i/:symbol', 'showInstrument', (req, res) => {++ define(router, 'get', '/i/:symbol', 'showInstrument', async (req, res) => {let raw = String(req.params.symbol);const asJson = raw.endsWith('.json');if (asJson) raw = raw.slice(0, -5);-- let instrument = instruments.bySymbol(raw);++ let instrument = await instruments.bySymbol(raw);if (!instrument) {-- const r = instruments.resolve(raw, { kind: 'ticker' });++ const r = await instruments.resolve(raw, { kind: 'ticker' });instrument = r.status === 'resolved' ? r.instrument : null;}if (!instrument) return asJson ? res.status(404).set('Cache-Control', 'private, no-store').json({ code: 'instrument.not_found', error: 'No such instrument' }) : common.notFound(req, res, `No instrument has the symbol “${raw}”.`);@@@ -81,10 +81,10 @@ function createPages(ctx) {}const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);-- const total = documents.count(instrument);++ const total = await documents.count(instrument);const pager = paginate({ page: pageNo, perPage: 25, total, href: (p) => (p === 1 ? urls.path.instrument(instrument) : `${urls.path.instrument(instrument)}?page=${p}`) });if (pager.outOfRange && total) return common.notFound(req, res);-- const data = reading.instrument(instrument, { limit: pager.limit, offset: pager.offset });++ const data = await reading.instrument(instrument, { limit: pager.limit, offset: pager.offset });const { decision } = data;if (asJson) {@@@ -105,8 +105,8 @@ function createPages(ctx) {personal: Boolean(signedIn),body: views.instrumentPage({...data, instrument, urls, pager, notice: common.noticeOf(req),-- context: data.context, pending: viewer.editor ? context.pending(instrument) : [],-- viewer, watchlists: signedIn ? watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),++ context: data.context, pending: viewer.editor ? await context.pending(instrument) : [],++ viewer, watchlists: signedIn ? await watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),}),});});@@@ -34,68 +34,68 @@ function createPrivate(ctx) {common.after(res, fallback, { e: e.message });}-- function instrumentFrom(body) {-- if (body.symbol) return instruments.bySymbol(body.symbol);-- const r = instruments.resolve(body.q);++ async function instrumentFrom(body) {++ if (body.symbol) return await instruments.bySymbol(body.symbol);++ const r = await instruments.resolve(body.q);return r.status === 'resolved' ? r.instrument : null;}-- define(router, 'get', '/watchlists', 'showWatchlists', (req, res) => {++ define(router, 'get', '/watchlists', 'showWatchlists', async (req, res) => {const v = req.viewer;const signedIn = v.kind === 'user' && v.subject;-- const lists = signedIn ? watchlists.forOwner(v.subject).map((w) => watchlists.dto(w)) : [];++ const lists = signedIn ? (await Promise.all((await watchlists.forOwner(v.subject)).map(async (w) => await watchlists.dto(w)))) : [];common.page(req, res, {title: 'Your watchlists', decision: privateDecision, personal: true,body: views.watchlistsPage({-- viewer: v, lists, rules: signedIn ? alerts.forOwner(v.subject) : [], deliveries: signedIn ? alerts.deliveries(v.subject, 20) : [],++ viewer: v, lists, rules: signedIn ? await alerts.forOwner(v.subject) : [], deliveries: signedIn ? await alerts.deliveries(v.subject, 20) : [],csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),}),});});-- define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), (req, res) => {++ define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), async (req, res) => {try {-- watchlists.create(req.viewer.subject, { name: req.body.name });++ await watchlists.create(req.viewer.subject, { name: req.body.name });common.after(res, '/watchlists', { n: 'watchlist_created' });} catch (err) { back(req, res, '/watchlists', err); }});-- define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {++ define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {try {-- watchlists.rename(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });++ await watchlists.rename(await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });common.after(res, '/watchlists', { n: 'watchlist_renamed' });} catch (err) { back(req, res, '/watchlists', err); }});-- define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {++ define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {try {-- watchlists.remove(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));++ await watchlists.remove(await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));common.after(res, '/watchlists', { n: 'watchlist_deleted' });} catch (err) { back(req, res, '/watchlists', err); }});-- define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {++ define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {try {-- const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);-- const instrument = instrumentFrom(req.body);-- const added = watchlists.add(w, instrument, req.body.note);++ const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);++ const instrument = await instrumentFrom(req.body);++ const added = await watchlists.add(w, instrument, req.body.note);const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';common.after(res, from, { n: added ? 'item_added' : 'item_present' });} catch (err) { back(req, res, '/watchlists', err); }});-- define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {++ define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {try {-- const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);-- watchlists.drop(w, instruments.bySymbol(req.body.symbol));++ const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);++ await watchlists.drop(w, await instruments.bySymbol(req.body.symbol));common.after(res, '/watchlists', { n: 'item_removed' });} catch (err) { back(req, res, '/watchlists', err); }});-- define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, B('trade.alert.create'), (req, res) => {-- const instrument = instruments.bySymbol(req.body.symbol);++ define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, B('trade.alert.create'), async (req, res) => {++ const instrument = await instruments.bySymbol(req.body.symbol);try {-- alerts.create(req.viewer.subject, instrument, {++ await alerts.create(req.viewer.subject, instrument, {kind: req.body.kind, metric: req.body.metric, operator: req.body.operator, threshold: req.body.threshold,unit: req.body.unit, currency: req.body.currency, form_types: req.body.form_types,});@@@ -107,9 +107,9 @@ function createPrivate(ctx) {}});-- define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, B('trade.alert.delete'), (req, res) => {++ define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, B('trade.alert.delete'), async (req, res) => {try {-- alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));++ await alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));common.after(res, '/watchlists', { n: 'alert_deleted' });} catch (err) { back(req, res, '/watchlists', err); }});@@@ -7,11 +7,12 @@*/const { createApp } = require('./app');--const { app, ctx } = createApp();++(async () => {++const { app, ctx } = await createApp();const { config } = ctx;const server = app.listen(config.port, config.host, () => {-- console.log(`[Trade] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Trade] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);console.log(`[Trade] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; Sources sync ${ctx.sync.enabled ? 'on' : 'off'}; worker ${config.worker.enabled ? 'on' : 'off'}`);});server.keepAliveTimeout = 65_000;@@@ -23,10 +24,11 @@ function shutdown(signal) {ctx.worker.stop();server.close(async () => {try { await ctx.outbox.stop(); } catch { /* best effort */ }-- try { ctx.store.close(); } catch { /* already closed */ }++ try { await ctx.store.close(); } catch { /* already closed */ }process.exit(0);});setTimeout(() => process.exit(0), 5000).unref();}process.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));++})().catch((err) => { console.error('[Trade] failed to start:', err); process.exit(1); });@@@ -13,7 +13,7 @@const { createReadiness } = require('openvibe-shared/ready');const { CHARTER_TABLES } = require('./db');--function createTradeReadiness({ store, auth, outbox, sync, freshness, release = null }) {++function createTradeReadiness({ store, auth, outbox, sync, freshness, valkey = null, release = null }) {const { db } = store;return createReadiness({service: 'trade',@@@ -21,12 +21,16 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =checks: [{name: 'db', required: true,-- check: () => {-- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));++ check: async () => {++ // A real round trip that names the store (postgresql / pglite), and the charter tables present.++ const r = await db.ready();++ if (!r.ok) return r.error;++ const names = new Set((await db.prepare('SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()').all()).map((x) => x.name));const missing = CHARTER_TABLES.filter((t) => !names.has(t));-- return missing.length ? `missing ${missing.join(', ')}` : true;++ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };},},++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },{name: 'network_jwks', required: false,check: () => {@@@ -37,8 +41,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =},{name: 'events_relay', required: false,-- check: () => {-- const s = outbox.status();++ check: async () => {++ const s = await outbox.status();if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;return { ok: true, detail: { pending: s.pending } };@@@ -46,8 +50,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =},{name: 'sources_sync', required: false,-- check: () => {-- const s = sync.state();++ check: async () => {++ const s = await sync.state();if (!s.enabled) return 'Sources sync off (OV_OAUTH_CLIENT_SECRET unset): no new filings or observations arrive';if (s.last_error) return `last Sources sync failed: ${s.last_error}`;if (!s.last_ok_at) return 'Sources sync has not completed yet';@@@ -56,8 +60,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =},{name: 'freshness', required: false,-- check: () => {-- const all = freshness.all();++ check: async () => {++ const all = await freshness.all();const stale = all.filter((s) => s.stale).map((s) => s.key);return stale.length ? `${stale.length} of ${all.length} sources stale: ${stale.slice(0, 10).join(', ')}` : { ok: true, detail: { sources: all.length } };},@@@ -275,8 +275,8 @@ ${pending.length ? html`<ul>${pending.map((p) => html`<li><a href="/editor/i/${e<ul>${instruments.map((i) => html`<li><a href="/editor/i/${encodeURIComponent(i.symbol)}">${i.symbol}</a> — ${i.name}</li>`)}</ul>`;}--function editorInstrument({ instrument, aliases, revisions, documents, observations, csrf, notice: n, kinds, head }) {-- const revs = revisions.map((r) => html`<li class="card">++async function editorInstrument({ instrument, aliases, revisions, documents, observations, csrf, notice: n, kinds, head }) {++ const revs = await revisions.map((r) => html`<li class="card"><p><strong>Revision ${r.revision}</strong> ${r.published ? html`<span class="badge fresh">published</span>` : ''} ${r.disclosure ? html`<span class="badge">${r.disclosure.short}</span>` : html`<span class="badge">editor</span>`}${r.needs_review ? html`<span class="badge stale">needs a person’s review</span>` : ''} · written ${when(r.written_at)} · as of ${r.as_of ? when(r.as_of) : 'no dated citation'}</p><div class="context-body">${raw(r.body_html)}</div>@@@ -13,10 +13,10 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = consolelet syncTimer = null;let freshTimer = null;-- function freshnessTick() {++ async function freshnessTick() {try {-- const changed = freshness.evaluateAll();-- const docs = indexing.refreshAll();++ const changed = await freshness.evaluateAll();++ const docs = await indexing.refreshAll();if (changed.length || docs.length) outbox.kick();return { sources: changed.length, documents: docs.length };} catch (err) {@@@ -53,17 +53,17 @@ const { boot, check, done } = require('./helpers/boot');await check('watchlists: 10 a minute per person, shared by the API and the form; nothing stored past it', async () => {clock = Date.UTC(2026, 8, 27, 12, 5, 0);-- const count = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlists').get().n;++ const count = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlists').get()).n;for (let i = 0; i < 10; i++) {const r = await t.get('/api/v1/watchlists', { as: alice, json: { name: `List ${i}` } });assert.strictEqual(r.status, 201, `watchlist ${i + 1}: ${r.text}`);}-- const before = count();++ const before = await count();const form = await t.get('/watchlists', { as: alice, form: { _csrf: t.csrf(alice), name: 'One too many' } });assert.deepStrictEqual([form.status, form.json().code, form.headers.get('retry-after')], [429, 'rate_limited', '60']);const viaService = await t.get('/api/v1/watchlists', { as: svc, headers: { 'x-ov-subject': alice.subject }, json: { name: 'Relayed' } });assert.strictEqual(viaService.status, 429, 'a service creating for the person shares their budget');-- assert.strictEqual(count(), before, 'nothing stored');++ assert.strictEqual(await count(), before, 'nothing stored');const other = await t.get('/api/v1/watchlists', { as: bob, json: { name: 'Bob list' } });assert.strictEqual(other.status, 201, `another person still creates: ${other.text}`);});@@@ -11,10 +11,10 @@ const MIN = 60e3;(async () => {const t = await boot();-- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });const iso = t.iso;-- const obs = (value, minutes, ref) => t.observe(acme, { metric: 'price.close', value, source_ref: ref, observed_at: iso(t.T0 + minutes * MIN), retrieved_at: iso(t.T0 + minutes * MIN) });-- const deliveries = () => t.ctx.store.db.prepare('SELECT * FROM trade_alert_deliveries ORDER BY created_at, id').all();++ const obs = async (value, minutes, ref) => await t.observe(acme, { metric: 'price.close', value, source_ref: ref, observed_at: iso(t.T0 + minutes * MIN), retrieved_at: iso(t.T0 + minutes * MIN) });++ const deliveries = async () => await t.ctx.store.db.prepare('SELECT * FROM trade_alert_deliveries ORDER BY created_at, id').all();let ruleId;await check('alice creates a threshold rule through the API (for herself only)', async () => {@@@ -26,14 +26,14 @@ const MIN = 60e3;});await check('an observation crossing the threshold delivers exactly once, with one event', async () => {-- obs('99.50', 1, 'o1');-- assert.strictEqual(deliveries().length, 0, 'below: nothing');-- const { observation } = obs('101.00', 2, 'o2');-- const d = deliveries();++ await obs('99.50', 1, 'o1');++ assert.strictEqual((await deliveries()).length, 0, 'below: nothing');++ const { observation } = await obs('101.00', 2, 'o2');++ const d = await deliveries();assert.strictEqual(d.length, 1);assert.strictEqual(d[0].trigger_kind, 'observation');assert.strictEqual(d[0].trigger_id, observation.id);-- const ev = t.events('trade.alert.triggered');++ const ev = await t.events('trade.alert.triggered');assert.strictEqual(ev.length, 1);assert.strictEqual(ev[0].visibility, 'subject');assert.deepStrictEqual(ev[0].subject, { type: 'user', id: t.alice.subject });@@@ -44,42 +44,42 @@ const MIN = 60e3;});await check('replaying the same observation (same source reference) delivers nothing new', async () => {-- const r = obs('101.00', 2, 'o2');++ const r = await obs('101.00', 2, 'o2');assert.strictEqual(r.created, false);const api = await t.get('/api/v1/observations', { as: t.network.serviceToken('feed', ['trade.observation.write']), json: { symbol: 'ACME', metric: 'price.close', value: '101.00', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'o2', observed_at: iso(t.T0 + 2 * MIN), retrieved_at: iso(t.T0 + 2 * MIN) } });assert.strictEqual(api.status, 200);-- assert.strictEqual(deliveries().length, 1);-- assert.strictEqual(t.events('trade.alert.triggered').length, 1);++ assert.strictEqual((await deliveries()).length, 1);++ assert.strictEqual((await t.events('trade.alert.triggered')).length, 1);});await check('re-running evaluation for the same observation cannot deliver twice (UNIQUE rule × trigger)', async () => {-- const o = t.ctx.store.db.prepare("SELECT * FROM trade_market_observations WHERE source_ref = 'o2'").get();-- t.ctx.store.db.prepare('UPDATE trade_alert_rules SET armed = 1, last_observed_at = NULL WHERE id = ?').run(ruleId);-- t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));-- t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));-- assert.strictEqual(deliveries().length, 1);-- assert.strictEqual(t.events('trade.alert.triggered').length, 1);++ const o = await t.ctx.store.db.prepare("SELECT * FROM trade_market_observations WHERE source_ref = 'o2'").get();++ await t.ctx.store.db.prepare('UPDATE trade_alert_rules SET armed = 1, last_observed_at = NULL WHERE id = ?').run(ruleId);++ await t.ctx.store.tx(async () => await t.ctx.alerts.onObservation(o, acme));++ await t.ctx.store.tx(async () => await t.ctx.alerts.onObservation(o, acme));++ assert.strictEqual((await deliveries()).length, 1);++ assert.strictEqual((await t.events('trade.alert.triggered')).length, 1);});await check('staying above does not repeat; dropping below re-arms; crossing again delivers once more', async () => {-- obs('102.00', 3, 'o3');-- assert.strictEqual(deliveries().length, 1, 'still above: no repeat');-- obs('98.00', 4, 'o4');-- assert.strictEqual(deliveries().length, 1);-- obs('100.50', 5, 'o5');-- assert.strictEqual(deliveries().length, 2);-- assert.strictEqual(t.events('trade.alert.triggered').length, 2);++ await obs('102.00', 3, 'o3');++ assert.strictEqual((await deliveries()).length, 1, 'still above: no repeat');++ await obs('98.00', 4, 'o4');++ assert.strictEqual((await deliveries()).length, 1);++ await obs('100.50', 5, 'o5');++ assert.strictEqual((await deliveries()).length, 2);++ assert.strictEqual((await t.events('trade.alert.triggered')).length, 2);});await check('a late observation (older than the last one evaluated) never fires', async () => {-- obs('97.00', 6, 'o6'); // re-arms-- obs('150.00', 1.5, 'late');-- assert.strictEqual(deliveries().length, 2);++ await obs('97.00', 6, 'o6'); // re-arms++ await obs('150.00', 1.5, 'late');++ assert.strictEqual((await deliveries()).length, 2);});await check('unit or currency mismatch never fires (no conversion)', async () => {-- t.observe(acme, { metric: 'price.close', value: '500', unit: 'EUR', currency: 'EUR', source_ref: 'eur', observed_at: iso(t.T0 + 7 * MIN), retrieved_at: iso(t.T0 + 7 * MIN) });-- assert.strictEqual(deliveries().length, 2);++ await t.observe(acme, { metric: 'price.close', value: '500', unit: 'EUR', currency: 'EUR', source_ref: 'eur', observed_at: iso(t.T0 + 7 * MIN), retrieved_at: iso(t.T0 + 7 * MIN) });++ assert.strictEqual((await deliveries()).length, 2);});await check('document rules: new_document and filing_type fire once per document; revisions of the item never re-fire', async () => {@@@ -90,17 +90,17 @@ const MIN = 60e3;const url = 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm';const item = t.sources.putItem({ canonical_url: url, title: 'ACME CORP', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });await t.ctx.sync.run();-- const bobs = () => deliveries().filter((d) => d.owner_subject === t.bob.subject);-- assert.strictEqual(bobs().length, 1, 'new_document fired, filing_type(8-K) did not for a 10-Q');++ const bobs = async () => (await deliveries()).filter((d) => d.owner_subject === t.bob.subject);++ assert.strictEqual((await bobs()).length, 1, 'new_document fired, filing_type(8-K) did not for a 10-Q');t.sources.putItem({ id: item.id, canonical_url: url, title: 'ACME CORP (amended title)', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });await t.ctx.sync.run();-- t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();++ await t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();await t.ctx.sync.run();-- assert.strictEqual(bobs().length, 1, 'item revision and page replay: no second delivery');++ assert.strictEqual((await bobs()).length, 1, 'item revision and page replay: no second delivery');assert.strictEqual((await t.get('/i/ACME.json')).json().documents[0].title, 'ACME CORP (amended title)');t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });await t.ctx.sync.run();-- assert.strictEqual(bobs().length, 3, 'an 8-K: new_document + filing_type');++ assert.strictEqual((await bobs()).length, 3, 'an 8-K: new_document + filing_type');});await check('rules and deliveries are private: someone else sees none and cannot delete them', async () => {@@@ -13,9 +13,9 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');(async () => {const t = await boot();-- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });-- const { observation } = t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });-- t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });++ const { observation } = await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });++ await t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });const ai = t.network.serviceToken('ai', ['trade.context.read', 'trade.context.propose']);let input;@@@ -60,14 +60,14 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');assert.ok(!page.text.includes('latest recorded close for ACME'), 'the draft is not shown to readers');assert.ok(page.text.includes('<meta name="robots" content="noindex, nofollow">'));assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));-- assert.strictEqual(t.events('trade.index_document.upserted').length, 0);++ assert.strictEqual((await t.events('trade.index_document.upserted')).length, 0);});await check('AI output cannot be published without a person, nor delivered as if by a person', async () => {const pub = await t.get(`/editor/i/ACME/context/${draftRev}/publish`, { as: t.editor, form: { _csrf: t.csrf(t.editor) } });assert.strictEqual(pub.status, 303);assert.ok(/review/i.test(errOf(pub)));-- assert.strictEqual(t.ctx.instruments.bySymbol('ACME').context_published_revision, null);++ assert.strictEqual((await t.ctx.instruments.bySymbol('ACME')).context_published_revision, null);const svcReview = await t.get(`/api/v1/instruments/ACME/context/revisions/${draftRev}/review`, { as: ai, json: { decision: 'approved' } });assert.strictEqual(svcReview.status, 403, 'a service cannot review');const noRun = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context' }, output: { summary: 'x' } } });@@@ -101,7 +101,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), page.text.match(/<meta name="robots"[^>]*>/)[0]);const sm = (await t.get('/sitemaps/instruments.xml')).text;assert.ok(sm.includes('https://openvibe.trade/i/ACME'));-- const up = t.events('trade.index_document.upserted');++ const up = await t.events('trade.index_document.upserted');assert.strictEqual(up.length, 1);assert.strictEqual(up[0].payload.authorship, 'ai_generated');assert.strictEqual(up[0].payload.visibility, 'public');@@@ -110,7 +110,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');});await check('an editor writes human context with a plain form; a stale base is refused without losing the published text', async () => {-- const head = t.ctx.context.head(t.ctx.instruments.bySymbol('ACME')).number;++ const head = (await t.ctx.context.head(await t.ctx.instruments.bySymbol('ACME'))).number;const ok = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme filed nothing new this week. ${WORDS(70)}`, expected_revision: String(head), cite: `observation:${observation.id}`, publish: '1' } });assert.strictEqual(ok.status, 303);assert.ok(/n=context_published/.test(ok.headers.get('location')));@@@ -126,7 +126,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');const r = await t.get('/editor/i/ACME/context/retract', { as: t.editor, form: { _csrf: t.csrf(t.editor) } });assert.strictEqual(r.status, 303);assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));-- assert.strictEqual(t.events('trade.index_document.deleted').length, 1);++ assert.strictEqual((await t.events('trade.index_document.deleted')).length, 1);});await check('the editor is for editors only; its pages are private and noindex', async () => {@@@ -138,7 +138,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');assert.strictEqual(e.headers.get('x-robots-tag'), 'noindex, nofollow');const add = await t.get('/editor/instruments', { as: t.editor, form: { _csrf: t.csrf(t.editor), symbol: 'nwco', name: 'New Co', kind: 'equity', cik: '' } });assert.strictEqual(add.status, 303);-- assert.ok(t.ctx.instruments.bySymbol('NWCO'));++ assert.ok(await t.ctx.instruments.bySymbol('NWCO'));});await t.close();@@@ -50,16 +50,16 @@ const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');await check('every envelope Trade actually produces validates as events.event-envelope@1 (and Search documents as search.index-document@1)', async () => {const t = await boot();-- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });-- t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 60 });++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ await t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 60 });await t.get('/api/v1/alerts', { as: t.alice, json: { symbol: 'ACME', kind: 'threshold', metric: 'price.close', operator: 'above', threshold: '1', unit: 'USD' } });t.clock.advance(1000);-- t.observe(acme, { metric: 'price.close', value: '2', source_ref: 'x', observed_at: t.iso(t.clock.now() - 1000), retrieved_at: t.iso(t.clock.now()) });++ await t.observe(acme, { metric: 'price.close', value: '2', source_ref: 'x', observed_at: t.iso(t.clock.now() - 1000), retrieved_at: t.iso(t.clock.now()) });t.clock.advance(3600e3);-- t.ctx.freshness.evaluateAll();-- const rev = t.ctx.context.propose({ kind: 'user', subject: t.editor.subject, editor: true }, acme, { body: Array.from({ length: 80 }, () => 'word').join(' ') });-- t.ctx.context.publish(acme, rev.revision.number);-- const all = t.events();++ await t.ctx.freshness.evaluateAll();++ const rev = await t.ctx.context.propose({ kind: 'user', subject: t.editor.subject, editor: true }, acme, { body: Array.from({ length: 80 }, () => 'word').join(' ') });++ await t.ctx.context.publish(acme, rev.revision.number);++ const all = await t.events();const types = new Set(all.map((e) => e.event_type));for (const ty of ['trade.observation.created', 'trade.alert.triggered', 'trade.source.stale', 'trade.index_document.upserted']) assert.ok(types.has(ty), `${ty} produced`);for (const e of all) {@@@ -15,7 +15,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';const t = await boot();const caps = ['trade.watchlist.read', 'trade.watchlist.create'];const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.trade'], cap: caps, extra });-- const secret = t.ctx.watchlists.create(t.alice.subject, { name: 'Alice secret plans' });++ const secret = await t.ctx.watchlists.create(t.alice.subject, { name: 'Alice secret plans' });await check('an app or module cannot read or write someone else\'s watchlists by naming them in X-OV-Subject', async () => {for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {@@@ -30,7 +30,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';assert.strictEqual(write.status, 403);}}-- assert.deepStrictEqual(t.ctx.watchlists.forOwner(t.alice.subject).map((w) => w.name), ['Alice secret plans']);++ assert.deepStrictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).map((w) => w.name), ['Alice secret plans']);});await check('an app acts for its on_behalf_of person (the header is optional and must match)', async () => {@@@ -10,12 +10,12 @@ const HOUR = 3600e3;(async () => {const t = await boot();-- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });const iso = t.iso;await check('an observation from a source with a known window is current inside the window', async () => {-- t.ctx.freshness.report('test-feed', { name: 'Test feed', status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 3600 });-- t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: iso(t.T0 - 60e3), retrieved_at: iso(t.T0) });++ await t.ctx.freshness.report('test-feed', { name: 'Test feed', status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 3600 });++ await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: iso(t.T0 - 60e3), retrieved_at: iso(t.T0) });const json = (await t.get('/i/ACME.json')).json();assert.strictEqual(json.observations[0].freshness.stale, false);assert.strictEqual(json.sources[0].status, 'fresh');@@@ -41,22 +41,22 @@ const HOUR = 3600e3;});await check('the transition emits trade.source.stale exactly once, and recovery emits trade.source.recovered once', async () => {-- t.ctx.freshness.evaluateAll();-- t.ctx.freshness.evaluateAll();-- const stale = t.events('trade.source.stale');++ await t.ctx.freshness.evaluateAll();++ await t.ctx.freshness.evaluateAll();++ const stale = await t.events('trade.source.stale');assert.strictEqual(stale.length, 1);assert.strictEqual(stale[0].payload.stale_since, iso(t.T0 + HOUR));-- t.observe(acme, { metric: 'price.close', value: '10.60', source_ref: 'b', observed_at: iso(t.clock.now() - 60e3), retrieved_at: iso(t.clock.now()) });-- t.ctx.freshness.evaluateAll();-- assert.strictEqual(t.events('trade.source.recovered').length, 1);-- assert.strictEqual(t.events('trade.source.stale').length, 1);++ await t.observe(acme, { metric: 'price.close', value: '10.60', source_ref: 'b', observed_at: iso(t.clock.now() - 60e3), retrieved_at: iso(t.clock.now()) });++ await t.ctx.freshness.evaluateAll();++ assert.strictEqual((await t.events('trade.source.recovered')).length, 1);++ assert.strictEqual((await t.events('trade.source.stale')).length, 1);const json = (await t.get('/i/ACME.json')).json();assert.strictEqual(json.observations[0].value, '10.60');assert.strictEqual(json.observations[0].freshness.stale, false);});await check('a value older than its own max_age is stale even while its source is fresh', async () => {-- t.observe(acme, { metric: 'eps.diluted', value: '1.23', unit: 'USD/share', source_ref: 'c', max_age_sec: 3600, observed_at: iso(t.clock.now() - 2 * HOUR), retrieved_at: iso(t.clock.now()) });++ await t.observe(acme, { metric: 'eps.diluted', value: '1.23', unit: 'USD/share', source_ref: 'c', max_age_sec: 3600, observed_at: iso(t.clock.now() - 2 * HOUR), retrieved_at: iso(t.clock.now()) });const o = (await t.get('/i/ACME.json')).json().observations.find((x) => x.metric === 'eps.diluted');assert.strictEqual(o.freshness.stale, true);assert.strictEqual(o.freshness.reason, 'value_older_than_max_age');@@@ -64,19 +64,19 @@ const HOUR = 3600e3;});await check('a source Trade has never heard from is "freshness unknown", never "current"', async () => {-- const v = t.ctx.freshness.view('nobody-knows');++ const v = await t.ctx.freshness.view('nobody-knows');assert.strictEqual(v.status, 'unknown');assert.strictEqual(v.stale, true);});await check('Sources unavailable: the sync records the failure and invents nothing; data decays to stale on Trade\'s clock', async () => {-- const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;++ const before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n;t.sources.setDown(true);const r = await t.ctx.sync.run();assert.strictEqual(r.ok, false);-- const state = t.ctx.sync.state();++ const state = await t.ctx.sync.state();assert.ok(state.last_error && /503/.test(state.last_error));-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, before);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n, before);t.clock.advance(5 * HOUR);const json = (await t.get('/i/ACME.json')).json();assert.ok(json.observations.every((o) => o.freshness.stale), 'everything is stale now');@@@ -89,10 +89,10 @@ const HOUR = 3600e3;});await check('the gate: a monetary observation older than TRADE_PRICE_MAX_AGE_SEC makes the page noindex (stale_price)', async () => {-- const acmeNow = t.ctx.instruments.bySymbol('ACME');-- assert.ok(!t.ctx.indexing.decide(acmeNow).codes.includes('stale_price'), 'observed hours ago: not yet');++ const acmeNow = await t.ctx.instruments.bySymbol('ACME');++ assert.ok(!(await t.ctx.indexing.decide(acmeNow)).codes.includes('stale_price'), 'observed hours ago: not yet');t.clock.advance(24 * HOUR);-- const d = t.ctx.indexing.decide(acmeNow);++ const d = await t.ctx.indexing.decide(acmeNow);assert.ok(d.codes.includes('stale_price'), d.codes.join(','));assert.ok(!d.indexable);});@@@ -48,11 +48,14 @@ async function boot(opts = {}) {const { createApp } = require('../../server/app');const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };++ const { createStore } = require('../../server/db');++ // One database per boot (PGlite, or TRADE_TEST_STORE=pg: the containers); a restart keeps it, like a file did.++ const testdb = await require('./db').testDb();let server = null;let built = null;async function start() {const config = configLib.load(env);-- built = createApp({ config, now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });await built.ctx.auth.ensureKey();server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.base = `http://127.0.0.1:${server.address().port}`;@@@ -61,7 +64,7 @@ async function boot(opts = {}) {}async function stop() {if (server) await new Promise((r) => server.close(r));-- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }server = null; built = null;}@@@ -78,8 +81,8 @@ async function boot(opts = {}) {return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };}-- function events(type = null) {-- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))++ async function events(type = null) {++ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));}@@@ -87,13 +90,13 @@ async function boot(opts = {}) {network, sources, clock, dbPath, editor, alice, bob, get, events, T0,csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.TRADE_FORM_SECRET }, user),iso: (ms) => new Date(ms).toISOString(),-- instrument(input) { return t.ctx.instruments.create({ kind: 'equity', ...input }, editor.subject); },++ async instrument(input) { return await t.ctx.instruments.create({ kind: 'equity', ...input }, editor.subject); },/** Record an observation as a first-party feed would (domain call, recordedBy svc:feed). */-- observe(instrument, input) {-- return t.ctx.observations.record({ source_key: 'test-feed', unit: 'USD', currency: 'USD', ...input }, instrument, { recordedBy: 'svc:feed' });++ async observe(instrument, input) {++ return await t.ctx.observations.record({ source_key: 'test-feed', unit: 'USD', currency: 'USD', ...input }, instrument, { recordedBy: 'svc:feed' });},async restart() { await stop(); await start(); },-- async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },++ async close() { await stop(); await testdb.close(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },};await start();return t;nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * TRADE_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.TRADE_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'trade', max });++++module.exports = { testDb, pgAvailable };@@@ -76,7 +76,7 @@ async function startSources({ network }) {const calls = [];let seq = 0;let down = false;-- const srv = await listen((req, raw, json) => {++ const srv = await listen(async (req, raw, json) => {calls.push({ method: req.method, url: req.url });if (down) return json(503, { code: 'down' });const token = String(req.headers.authorization || '').slice(7);@@@ -97,7 +97,7 @@ async function startSources({ network }) {const page = rows.slice(0, limit);const keys = [...new Set(page.map((i) => i.source_key))];const health = {};-- for (const k of keys) if (sources.has(k)) health[k] = sources.get(k).health;++ for (const k of keys) if (sources.has(k)) health[k] = (await sources.get(k)).health;return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rows.length > limit, sources: health });}return json(404, { code: 'route.not_found' });@@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/boot');(async () => {const t = await boot();const feed = t.network.serviceToken('feed', ['trade.observation.write']);-- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });await check('an instrument without observations shows no number, in HTML and JSON', async () => {const page = await t.get('/i/ACME');@@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(future.status, 422);const noValue = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: 'about ten', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0), retrieved_at: t.iso(t.T0) } });assert.strictEqual(noValue.status, 422);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n, 0);});await check('only a service with trade.observation.write records observations; people cannot', async () => {@@@ -61,7 +61,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(json.observations.length, 1);for (const k of ['observed_at', 'retrieved_at', 'recorded_at']) assert.ok(json.observations[0][k], k);assert.ok(json.observations[0].freshness, 'every observation carries its freshness');-- const ev = t.events('trade.observation.created');++ const ev = await t.events('trade.observation.created');assert.strictEqual(ev.length, 1);assert.strictEqual(ev[0].payload.observed_at, '2026-09-22T11:59:00.000Z');assert.strictEqual(ev[0].payload.retrieved_at, '2026-09-22T12:00:00.000Z');@@@ -74,16 +74,16 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(same.json().observation.id, obsId);const diff = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '11', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });assert.strictEqual(diff.status, 409);-- assert.strictEqual(t.events('trade.observation.created').length, 1);++ assert.strictEqual((await t.events('trade.observation.created')).length, 1);});await check('observations are immutable in the database', async () => {-- assert.throws(() => t.ctx.store.db.prepare("UPDATE trade_market_observations SET value = '99' WHERE id = ?").run(obsId), /immutable/);-- assert.throws(() => t.ctx.store.db.prepare('DELETE FROM trade_market_observations WHERE id = ?').run(obsId), /immutable/);++ await assert.rejects(async () => await t.ctx.store.db.prepare("UPDATE trade_market_observations SET value = '99' WHERE id = ?").run(obsId), /immutable/);++ await assert.rejects(async () => await t.ctx.store.db.prepare('DELETE FROM trade_market_observations WHERE id = ?').run(obsId), /immutable/);});await check('history keeps every observation, newest first, each with its timestamps', async () => {-- t.observe(acme, { metric: 'price.close', value: '10.75', source_ref: 'r2', observed_at: '2026-09-22T12:01:00Z', retrieved_at: '2026-09-22T12:02:00Z' });++ await t.observe(acme, { metric: 'price.close', value: '10.75', source_ref: 'r2', observed_at: '2026-09-22T12:01:00Z', retrieved_at: '2026-09-22T12:02:00Z' });const h = (await t.get('/api/v1/instruments/ACME/observations?metric=price.close')).json();assert.deepStrictEqual(h.observations.map((o) => o.value), ['10.75', '10.50']);const latest = (await t.get('/i/ACME.json')).json().observations;@@@ -13,8 +13,8 @@ const DISCLAIMER = 'Information only — not investment advice; no trading here.(async () => {const t = await boot();-- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567', exchange: 'NYSE' });-- t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567', exchange: 'NYSE' });++ await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });t.sources.setSource('sec-xbrl-filings', { lastSuccessAt: t.iso(t.T0), staleAfterSec: 2700 });t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm', title: 'ACME CORP', summary: '10-K', published_at: '2026-09-22T10:00:00Z', retrieved_at: t.iso(t.T0) });t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', retrieved_at: t.iso(t.T0) });@@@ -104,7 +104,7 @@ const DISCLAIMER = 'Information only — not investment advice; no trading here.await check('the historical marketplace branch is an open question in docs, and nothing of it is implemented', async () => {const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'marketplace-open-question.md'), 'utf8');assert.ok(/ADR-025/.test(doc) && /unresolved|open question/i.test(doc));-- const tables = t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);++ const tables = (await t.ctx.store.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name);assert.ok(!tables.some((n) => /order|listing|escrow|wallet|custody|payment|cart/.test(n)), tables.join(','));});@@@ -9,8 +9,8 @@ const { boot, check, done } = require('./helpers/boot');(async () => {const t = await boot();-- t.instrument({ symbol: 'ACME', name: 'Acme Corp' });-- t.instrument({ symbol: 'ZETA', name: 'Zeta Holdings' });++ await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ await t.instrument({ symbol: 'ZETA', name: 'Zeta Holdings' });const form = (user, fields) => ({ as: user, form: { _csrf: t.csrf(user), ...fields } });let wlId;@@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/boot');const c = await t.get('/watchlists', form(t.alice, { name: 'Secret plans' }));assert.strictEqual(c.status, 303);assert.ok(/private, no-store/.test(c.headers.get('cache-control')));-- wlId = t.ctx.watchlists.forOwner(t.alice.subject)[0].id;++ wlId = (await t.ctx.watchlists.forOwner(t.alice.subject))[0].id;assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, symbol: 'ACME' }))).status, 303);assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, q: 'Zeta Holdings' }))).status, 303);const page = await t.get('/watchlists', { as: t.alice });@@@ -42,7 +42,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(r.status, 403);const r2 = await t.get('/watchlists', { as: t.alice, form: { _csrf: t.csrf(t.bob), name: 'Injected' } });assert.strictEqual(r2.status, 403);-- assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1);++ assert.strictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).length, 1);});await check('bob cannot see, change or delete alice\'s watchlist (404, existence not disclosed)', async () => {@@@ -55,7 +55,7 @@ const { boot, check, done } = require('./helpers/boot');const del = await t.get('/watchlists/delete', form(t.bob, { watchlist_id: wlId }));assert.strictEqual(del.status, 303);assert.strictEqual(new URL(del.headers.get('location'), 'https://x').searchParams.get('e'), 'No such watchlist');-- assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1, 'still there');++ assert.strictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).length, 1, 'still there');assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');});@@@ -102,17 +102,17 @@ const { boot, check, done } = require('./helpers/boot');assert.ok(!all.includes(t.alice.subject));const robots = (await t.get('/robots.txt')).text;for (const p of ['/watchlists', '/alerts', '/editor', '/api/']) assert.ok(robots.includes(`Disallow: ${p}`), p);-- const events = JSON.stringify(t.events());++ const events = JSON.stringify(await t.events());assert.ok(!events.includes(wlId) && !events.includes('Secret plans') && !events.includes('watchlist'));-- assert.ok(!t.events().some((e) => e.event_type.includes('watchlist')));++ assert.ok(!(await t.events()).some((e) => e.event_type.includes('watchlist')));});await check('removing and deleting work for the owner', async () => {assert.strictEqual((await t.get('/watchlists/item/remove', form(t.alice, { watchlist_id: wlId, symbol: 'ZETA' }))).status, 303);-- assert.deepStrictEqual(t.ctx.watchlists.items(t.ctx.watchlists.forOwner(t.alice.subject)[0]).map((i) => i.symbol), ['ACME']);++ assert.deepStrictEqual((await t.ctx.watchlists.items((await t.ctx.watchlists.forOwner(t.alice.subject))[0])).map((i) => i.symbol), ['ACME']);assert.strictEqual((await t.get('/watchlists/delete', form(t.alice, { watchlist_id: wlId }))).status, 303);-- assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 0);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?').get(wlId).n, 0);++ assert.strictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).length, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?').get(wlId)).n, 0);});await t.close();@@@ -12,9 +12,9 @@ const { mapItem } = require('../server/domain/mapping');(async () => {const t = await boot();-- const apple = t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'NASDAQ' });-- t.instrument({ symbol: 'ACME', name: 'Acme Corp' });-- t.instrument({ symbol: 'ACMX', name: 'ACME CORPORATION' }); // normalises to the same name as ACME Corp++ const apple = await t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'NASDAQ' });++ await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ await t.instrument({ symbol: 'ACMX', name: 'ACME CORPORATION' }); // normalises to the same name as ACME Corpconst ret = t.iso(t.T0 - 5 * 60e3);t.sources.setSource('sec-xbrl-filings', { name: 'SEC EDGAR: latest XBRL filings', lastSuccessAt: ret, staleAfterSec: 2700 });@@@ -54,50 +54,50 @@ const { mapItem } = require('../server/domain/mapping');assert.strictEqual(d.retrieved_at, ret);assert.strictEqual(d.source.item_id, filing.id);assert.deepStrictEqual(json.observations.map((o) => [o.metric, o.value, o.observed_at]), [['price.close', '231.40', '2026-09-21T20:00:00.000Z']]);-- assert.strictEqual(t.ctx.sync.state().cursor, 4);++ assert.strictEqual((await t.ctx.sync.state()).cursor, 4);const src = json.sources.find((s) => s.key === 'sec-xbrl-filings');assert.strictEqual(src.name, 'SEC EDGAR: latest XBRL filings');assert.strictEqual(src.stale_after_sec, 2700);});await check('replaying the same Sources pages (cursor reset) creates nothing new', async () => {-- const docs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n;-- const obs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;-- const evts = t.events().length;-- t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();++ const docs = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get()).n;++ const obs = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n;++ const evts = (await t.events()).length;++ await t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();const r = await t.ctx.sync.run();assert.strictEqual(r.ok, true);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n, docs);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, obs);-- assert.strictEqual(t.events('trade.observation.created').length, 1);-- assert.strictEqual(t.events().length, evts, 'no new events at all');++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get()).n, docs);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n, obs);++ assert.strictEqual((await t.events('trade.observation.created')).length, 1);++ assert.strictEqual((await t.events()).length, evts, 'no new events at all');});await check('a removed Sources item hides the document (the row stays as the record)', async () => {t.sources.remove(filing.id, { at: t.iso(t.T0), reason: 'takedown request' });await t.ctx.sync.run();assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 0);-- const row = t.ctx.store.db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?').get(filing.id);++ const row = await t.ctx.store.db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?').get(filing.id);assert.strictEqual(row.removed_reason, 'takedown request');const feed = await t.get('/i/AAPL/documents.xml');assert.ok(!feed.text.includes('10-Q'), 'gone from the feed too');});await check('resolution is deterministic: CIK, then ticker, then exact normalised name; several names → ambiguous', async () => {-- const r = (q, k) => t.ctx.instruments.resolve(q, k ? { kind: k } : {});-- assert.strictEqual(r('320193').instrument.symbol, 'AAPL');-- assert.strictEqual(r('CIK0000320193').match.kind, 'cik');-- assert.strictEqual(r('aapl').instrument.symbol, 'AAPL');-- assert.strictEqual(r('Apple Inc').instrument.symbol, 'AAPL');-- assert.strictEqual(r('APPLE, INC.').instrument.symbol, 'AAPL');-- const amb = r('Acme Corporation');++ const r = async (q, k) => await t.ctx.instruments.resolve(q, k ? { kind: k } : {});++ assert.strictEqual((await r('320193')).instrument.symbol, 'AAPL');++ assert.strictEqual((await r('CIK0000320193')).match.kind, 'cik');++ assert.strictEqual((await r('aapl')).instrument.symbol, 'AAPL');++ assert.strictEqual((await r('Apple Inc')).instrument.symbol, 'AAPL');++ assert.strictEqual((await r('APPLE, INC.')).instrument.symbol, 'AAPL');++ const amb = await r('Acme Corporation');assert.strictEqual(amb.status, 'ambiguous');assert.deepStrictEqual(amb.candidates.map((i) => i.symbol), ['ACME', 'ACMX']);assert.strictEqual(amb.instrument, null, 'none is chosen');-- assert.strictEqual(r('Appl').status, 'not_found', 'no prefix or fuzzy matching');-- t.ctx.instruments.addAlias(apple, 'ticker', 'APC', t.editor.subject);-- assert.strictEqual(r('APC').match.kind, 'ticker');-- for (let i = 0; i < 3; i++) assert.deepStrictEqual(r('Acme Corporation').candidates.map((x) => x.id), amb.candidates.map((x) => x.id));++ assert.strictEqual((await r('Appl')).status, 'not_found', 'no prefix or fuzzy matching');++ await t.ctx.instruments.addAlias(apple, 'ticker', 'APC', t.editor.subject);++ assert.strictEqual((await r('APC')).match.kind, 'ticker');++ for (let i = 0; i < 3; i++) assert.deepStrictEqual((await r('Acme Corporation')).candidates.map((x) => x.id), amb.candidates.map((x) => x.id));const page = await t.get('/i/APC');assert.strictEqual(page.status, 301);assert.strictEqual(page.headers.get('location'), '/i/AAPL');@@@ -110,8 +110,8 @@ const { mapItem } = require('../server/domain/mapping');await check('a ticker or CIK names one instrument: collisions are 409', async () => {const ed = t.editor;assert.strictEqual((await t.get('/api/v1/instruments', { as: { ...ed, role: 'user' }, json: { symbol: 'APC', name: 'Other' } })).status, 409);-- const acme = t.ctx.instruments.bySymbol('ACME');-- assert.throws(() => t.ctx.instruments.addAlias(acme, 'cik', '320193'), (e) => e.status === 409);++ const acme = await t.ctx.instruments.bySymbol('ACME');++ await assert.rejects(async () => await t.ctx.instruments.addAlias(acme, 'cik', '320193'), (e) => e.status === 409);});await check('the signed webhook wakes the sync once per event id; a bad signature, v1-only or stale v2 is 401', async () => {
@@ -20,6 +20,14 @@ TRUST_PROXY=2
@@ -20,6 +20,14 @@ TRUST_PROXY=2
#TRADE_LIMITS_HOUR=3000
#TRADE_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-trade/trade.db)
# SQLite database (production unit: /var/lib/openvibe-trade/trade.db)
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh trade: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=
+# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
TRADE_DB_PATH=./data/trade.db
TRADE_DB_PATH=./data/trade.db
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
@@ -9,6 +9,9 @@ jobs:
@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: trade
contracts-service: trade
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -29,7 +29,7 @@ not as a table. The historical marketplace branch is recorded as an open questio
@@ -29,7 +29,7 @@ not as a table. The historical marketplace branch is recorded as an open questio
## Owns
## Owns
-The nine charter tables (roadmap §15.13) live in Trade's own SQLite (`TRADE_DB_PATH`):
+The nine charter tables (roadmap §15.13) live in Trade's own PostgreSQL database (`ov_trade` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
| Charter table | What it is |
| Charter table | What it is |
|---|---|
|---|---|
@@ -208,9 +208,11 @@ services need to call Trade, is under [Grants the Network must hold](#grants-the
@@ -208,9 +208,11 @@ services need to call Trade, is under [Grants the Network must hold](#grants-the
## Depends on
## Depends on
-- **Packages** (pinned by release tarball): `openvibe-contracts` v0.49.0, `openvibe-publishing`
- v0.4.0 (revisions, authorship, seo, index-hooks, ssr), `openvibe-shared` v1.25.0 (chrome, app icon,
- footer, legal, release, metrics, ready), `openvibe-sdk` v0.12.0 (events outbox and inbox, webhook
+- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
+ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
+- **Packages** (pinned by release tarball): `openvibe-contracts` v0.76.0, `openvibe-publishing`
+ v1.0.0 (revisions, authorship, seo, index-hooks, ssr), `openvibe-shared` v1.25.0 (Frame, app icon,
+ footer, legal, release, metrics, ready), `openvibe-sdk` v0.20.0 (events outbox and inbox, webhook
signatures v2, service tokens, per-actor limits).
signatures v2, service tokens, per-actor limits).
- **OpenVibe.Network:** SSO (OAuth client `trade`, redirect `https://openvibe.trade/auth/callback`),
- **OpenVibe.Network:** SSO (OAuth client `trade`, redirect `https://openvibe.trade/auth/callback`),
JWKS, client-credentials tokens.
JWKS, client-credentials tokens.
@@ -342,10 +344,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4860 (set OV_OAUTH
@@ -342,10 +344,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4860 (set OV_OAUTH
Production deploys with `sudo ovhost deploy trade` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy trade` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.trade`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.trade`, install on a lockfile change, restart, wait for `/api/ready`).
-The unit is `openvibe-trade.service` on `127.0.0.1:4860`, the env file `/etc/openvibe/trade.env`.
+The unit is `openvibe-trade.service` on `127.0.0.1:4860`, the env file `/etc/openvibe/trade.env`. The database is
+`ov_trade` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh trade` writes its settings); the
+release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
+`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old
+`/var/lib/openvibe-trade/trade.db` stays read-only for 7 days as the rollback.
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
-restart; afterwards `sudo ovhost rollback trade --to <sha>`. Nothing blocks a rollback: the schema
-code only adds tables and columns.
+restart; afterwards `sudo ovhost rollback trade --to <sha>`. Migrations only add tables and columns.
First install (done once; kept for a rebuild):
First install (done once; kept for a rebuild):
@@ -4,12 +4,17 @@
@@ -4,12 +4,17 @@
"code": true,
"code": true,
"wave": "W19",
"wave": "W19",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express 4, better-sqlite3 11",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4860,
"port": 4860,
"domain": "openvibe.trade",
"domain": "openvibe.trade",
"scope": "informational only (ADR-025): no custody, no order execution, no escrow, no listings between users, no personalised advice",
"scope": "informational only (ADR-025): no custody, no order execution, no escrow, no listings between users, no personalised advice",
- "contracts": "openvibe-contracts v0.49.0",
- "packages": ["openvibe-contracts v0.49.0", "openvibe-publishing v0.4.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
+ "contracts": "openvibe-contracts v0.76.0",
+ "packages": [
+ "openvibe-contracts v0.76.0",
+ "openvibe-publishing v1.0.0",
+ "openvibe-shared v1.25.0",
+ "openvibe-sdk v0.20.0"
+ ],
"capabilities": [
"capabilities": [
"trade.instrument.resolve",
"trade.instrument.resolve",
"trade.instrument.manage",
"trade.instrument.manage",
@@ -3,8 +3,8 @@ Description=OpenVibe.Trade — informational market context (no custody, no orde
@@ -3,8 +3,8 @@ Description=OpenVibe.Trade — informational market context (no custody, no orde
# Network (sign-in, JWKS, service tokens), Sources (the trade category) and Events (the outbox
# Network (sign-in, JWKS, service tokens), Sources (the trade category) and Events (the outbox
# relay) should be up first, but Trade degrades gracefully without them: pages and feeds serve what
# relay) should be up first, but Trade degrades gracefully without them: pages and feeds serve what
# was recorded, marked stale as it ages; sign-in, sync and events wait (see /api/ready).
# was recorded, marked stale as it ages; sign-in, sync and events wait (see /api/ready).
-After=network.target
-Wants=openvibe-network.service
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
new file mode 100644
new file mode 100644
@@ -0,0 +1,272 @@
@@ -0,0 +1,272 @@
+-- phase: expand
+-- OpenVibe.Trade on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
+-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
+-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.
+
+CREATE TABLE trade_instruments (
+ id text COLLATE "C" PRIMARY KEY, -- ins_<ULID>
+ symbol text COLLATE "C" NOT NULL UNIQUE, -- canonical display ticker, upper case: /i/:symbol
+ name text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('equity','fund','index','currency','commodity','crypto','other')),
+ exchange text COLLATE "C", -- as an editor recorded it; NULL = not recorded
+ cik text COLLATE "C" UNIQUE, -- SEC Central Index Key, 10 digits zero-padded
+ currency text COLLATE "C", -- ISO 4217 trading currency, when recorded
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
+ context_published_revision bigint, -- trade_context_revisions.number readers see
+ context_published_at bigint,
+ created_by text COLLATE "C", -- usr_… or svc:…
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE trade_instrument_aliases (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('ticker','cik','name')),
+ value text COLLATE "C" NOT NULL, -- as entered
+ normalized text COLLATE "C" NOT NULL, -- the resolution key (see domain/instruments.js)
+ added_by text COLLATE "C",
+ created_at bigint NOT NULL,
+ UNIQUE (instrument_id, kind, normalized)
+);
+-- A ticker or CIK names exactly one instrument; a name may be shared (resolution is then ambiguous).
+CREATE UNIQUE INDEX trade_aliases_unique_key ON trade_instrument_aliases (kind, normalized) WHERE kind IN ('ticker','cik');
+CREATE INDEX trade_aliases_lookup ON trade_instrument_aliases (kind, normalized);
+
+CREATE TABLE trade_watchlists (
+ id text COLLATE "C" PRIMARY KEY, -- wl_<ULID>
+ owner_subject text COLLATE "C" NOT NULL, -- usr_…; never shown to anyone else
+ name text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ UNIQUE (owner_subject, name)
+);
+
+CREATE TABLE trade_watchlist_items (
+ watchlist_id text COLLATE "C" NOT NULL REFERENCES trade_watchlists(id) ON DELETE CASCADE,
+ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),
+ note text COLLATE "C",
+ added_at bigint NOT NULL,
+ PRIMARY KEY (watchlist_id, instrument_id)
+);
+
+CREATE TABLE trade_market_observations (
+ id text COLLATE "C" PRIMARY KEY, -- obs_<ULID>
+ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),
+ metric text COLLATE "C" NOT NULL, -- e.g. price.close, volume, us-gaap:Revenues
+ value text COLLATE "C" NOT NULL, -- the decimal exactly as the source stated it
+ value_num double precision NOT NULL, -- the same value, for comparisons only
+ unit text COLLATE "C" NOT NULL, -- USD, shares, USD/share, pure, …
+ currency text COLLATE "C", -- ISO 4217 when the value is monetary
+ period text COLLATE "C", -- e.g. 2026-Q2, when the source states one
+ observed_at bigint NOT NULL, -- when the value was true, per the source
+ source_key text COLLATE "C" NOT NULL, -- OpenVibe.Sources source key
+ source_item_id text COLLATE "C", -- itm_… when it came from a Sources item
+ source_url text COLLATE "C",
+ source_ref text COLLATE "C" NOT NULL, -- the source's own identity of this datum
+ retrieved_at bigint NOT NULL, -- when the source was fetched
+ max_age_sec bigint, -- the value is stale after this long (NULL: only the source's freshness)
+ recorded_by text COLLATE "C" NOT NULL, -- svc:… (a feed) or usr_… (never inferred)
+ recorded_at bigint NOT NULL,
+ UNIQUE (source_key, source_ref)
+);
+CREATE INDEX trade_obs_series ON trade_market_observations (instrument_id, metric, observed_at);
+CREATE FUNCTION trade_obs_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'trade_market_observations rows are immutable'; END $$;
+CREATE TRIGGER trade_obs_no_update BEFORE UPDATE ON trade_market_observations FOR EACH ROW EXECUTE FUNCTION trade_obs_no_update();
+CREATE FUNCTION trade_obs_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'trade_market_observations rows are immutable'; END $$;
+CREATE TRIGGER trade_obs_no_delete BEFORE DELETE ON trade_market_observations FOR EACH ROW EXECUTE FUNCTION trade_obs_no_delete();
+
+CREATE TABLE trade_source_documents (
+ id text COLLATE "C" PRIMARY KEY, -- doc_<ULID>
+ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),
+ source_key text COLLATE "C" NOT NULL,
+ source_item_id text COLLATE "C" NOT NULL UNIQUE, -- itm_…: Sources owns the item
+ source_revision bigint NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('filing','article','record')),
+ form_type text COLLATE "C", -- 10-K, 8-K, … when the source states it
+ title text COLLATE "C",
+ filer_name text COLLATE "C",
+ cik text COLLATE "C",
+ accession text COLLATE "C",
+ url text COLLATE "C",
+ published_at bigint, -- the source's own date; NULL if it gave none
+ source_updated_at bigint,
+ retrieved_at bigint NOT NULL,
+ first_seen_at bigint NOT NULL,
+ license_note text COLLATE "C",
+ terms_note text COLLATE "C",
+ removed_at bigint,
+ removed_reason text COLLATE "C",
+ recorded_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX trade_docs_instrument ON trade_source_documents (instrument_id, removed_at, published_at);
+
+CREATE TABLE trade_alert_rules (
+ id text COLLATE "C" PRIMARY KEY, -- alr_<ULID>
+ owner_subject text COLLATE "C" NOT NULL,
+ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('threshold','filing_type','new_document')),
+ metric text COLLATE "C",
+ operator text COLLATE "C" CHECK (operator IN ('above','below')),
+ threshold text COLLATE "C",
+ threshold_num double precision,
+ unit text COLLATE "C",
+ currency text COLLATE "C",
+ form_types text COLLATE "C", -- JSON array (filing_type rules)
+ armed bigint NOT NULL DEFAULT 1, -- threshold rules fire on a crossing, then re-arm
+ last_observed_at bigint, -- newest observation a threshold rule evaluated
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ CHECK (kind <> 'threshold' OR (metric IS NOT NULL AND operator IS NOT NULL AND threshold IS NOT NULL AND unit IS NOT NULL)),
+ CHECK (kind <> 'filing_type' OR form_types IS NOT NULL)
+);
+CREATE INDEX trade_rules_owner ON trade_alert_rules (owner_subject, status);
+CREATE INDEX trade_rules_instrument ON trade_alert_rules (instrument_id, status, kind);
+
+CREATE TABLE trade_alert_deliveries (
+ id text COLLATE "C" PRIMARY KEY, -- ald_<ULID>
+ rule_id text COLLATE "C" NOT NULL REFERENCES trade_alert_rules(id),
+ owner_subject text COLLATE "C" NOT NULL,
+ trigger_kind text COLLATE "C" NOT NULL CHECK (trigger_kind IN ('observation','document')),
+ trigger_id text COLLATE "C" NOT NULL,
+ event_id text COLLATE "C", -- the trade.alert.triggered envelope
+ summary text COLLATE "C" NOT NULL, -- JSON snapshot of what triggered it
+ created_at bigint NOT NULL,
+ UNIQUE (rule_id, trigger_kind, trigger_id)
+);
+CREATE INDEX trade_deliveries_owner ON trade_alert_deliveries (owner_subject, created_at);
+
+-- Freshness of each source Trade shows data from (not a charter table; a projection of Sources'
+-- health plus Trade's own clock).
+CREATE TABLE trade_source_status (
+ source_key text COLLATE "C" PRIMARY KEY,
+ name text COLLATE "C",
+ upstream_status text COLLATE "C", -- Sources' health status, NULL = never reported
+ last_success_at bigint, -- last successful fetch (Sources, or the newest retrieval Trade recorded)
+ stale_after_sec bigint,
+ reported_at bigint, -- when Sources last told us about it
+ stale bigint NOT NULL DEFAULT 1, -- as of the last evaluation
+ stale_since bigint,
+ evaluated_at bigint,
+ terms_note text COLLATE "C",
+ license_note text COLLATE "C"
+);
+
+CREATE TABLE trade_sync_state (
+ name text COLLATE "C" PRIMARY KEY,
+ cursor bigint NOT NULL DEFAULT 0,
+ last_ok_at bigint,
+ last_error text COLLATE "C",
+ last_error_at bigint,
+ counts text COLLATE "C" NOT NULL DEFAULT '{}'
+);
+
+-- openvibe-publishing/revisions (prefix trade_context)
+CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_revisions_entity_num ON trade_context_revisions (entity_id, number);
+CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_drafts_updated ON trade_context_drafts (entity_id, updated_at DESC, owner);
+CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP = 'UPDATE' THEN
+ RAISE EXCEPTION 'trade_context_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM trade_context_revision_purges WHERE entity_id = OLD.entity_id) THEN
+ RAISE EXCEPTION 'trade_context_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
+ END IF;
+ RETURN OLD;
+END
+$$;
+CREATE OR REPLACE TRIGGER trade_context_revisions_no_update BEFORE UPDATE ON trade_context_revisions FOR EACH ROW EXECUTE FUNCTION trade_context_revisions_guard();
+CREATE OR REPLACE TRIGGER trade_context_revisions_no_delete BEFORE DELETE ON trade_context_revisions FOR EACH ROW EXECUTE FUNCTION trade_context_revisions_guard();
+
+-- openvibe-publishing/authorship (prefix trade_context)
+CREATE TABLE IF NOT EXISTS trade_context_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 trade_context_reviews_rev ON trade_context_reviews (entity_id, revision, id);
+CREATE INDEX IF NOT EXISTS trade_context_reviews_entity ON trade_context_reviews (entity_id, id);
+CREATE OR REPLACE FUNCTION trade_context_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP = 'UPDATE' THEN
+ RAISE EXCEPTION 'trade_context_reviews rows are immutable' USING ERRCODE = 'restrict_violation';
+ END IF;
+ RETURN OLD;
+END
+$$;
+CREATE OR REPLACE TRIGGER trade_context_reviews_no_update BEFORE UPDATE ON trade_context_reviews FOR EACH ROW EXECUTE FUNCTION trade_context_reviews_guard();
+
+-- openvibe-publishing/index-hooks (prefix trade)
+CREATE TABLE IF NOT EXISTS trade_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)
+);
+
+-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
+CREATE TABLE IF NOT EXISTS idempotency_receipts (
+ consumer text NOT NULL,
+ event_id text NOT NULL,
+ processed_at bigint NOT NULL,
+ PRIMARY KEY (consumer, event_id)
+);
+
+-- openvibe-sdk/events PostgreSQL outbox
+CREATE TABLE IF NOT EXISTS event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
@@ -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.49.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.20.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.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "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.20.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
+ "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==",
"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,14 +2,21 @@
@@ -2,14 +2,21 @@
"name": "openvibe-trade",
"name": "openvibe-trade",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Trade — informational market context: instruments and aliases, timestamped market observations with their sources, filings from OpenVibe.Sources, private watchlists and alerts, and reviewed context. Information only: no custody, no order execution, no advice.",
+ "description": "OpenVibe.Trade \u2014 informational market context: instruments and aliases, timestamped market observations with their sources, filings from OpenVibe.Sources, private watchlists and alerts, and reviewed context. Information only: no custody, no order execution, no advice.",
"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",
+ "test:pg": "TRADE_TEST_STORE=pg node test/run.js"
},
},
- "keywords": ["openvibe", "trade", "watchlists", "filings", "informational"],
+ "keywords": [
+ "openvibe",
+ "trade",
+ "watchlists",
+ "filings",
+ "informational"
+ ],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Trade",
"repository": "github:OpenVibers/OpenVibe.Trade",
@@ -20,13 +27,16 @@
@@ -20,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.49.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.20.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"
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Trade's SQLite database (TRADE_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
+ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
+ *
+ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
+ *
+ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts
+ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its
+ * name and columns.
+ */
+require('dotenv').config();
+const { runSqliteMigration } = require('openvibe-sdk/db');
+const configLib = require('../server/config');
+const { MIGRATIONS } = require('../server/db');
+
+const TABLES = {};
+
+if (require.main === module) {
+ const config = configLib.load();
+ runSqliteMigration({ service: 'trade', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
+ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
+}
+
+module.exports = { TABLES };
@@ -57,11 +57,12 @@ const VERSION = require('../package.json').version;
@@ -57,11 +57,12 @@ const VERSION = require('../package.json').version;
/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,
/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,
* limitsNow (the per-actor limiter's clock, tests; default the wall clock) */
* limitsNow (the per-actor limiter's clock, tests; default the wall clock) */
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
+ const store = opts.store || await openStore(config, { now: opts.now, log });
// One context object: domain modules reach each other through it at call time.
// One context object: domain modules reach each other through it at call time.
const ctx = { config, store, log };
const ctx = { config, store, log };
@@ -93,7 +94,10 @@ function createApp(opts = {}) {
@@ -93,7 +94,10 @@ function createApp(opts = {}) {
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Per-actor limits (http/actor-limits.js) on /api/v1 and the forms, counted once each router resolved
// Per-actor limits (http/actor-limits.js) on /api/v1 and the forms, counted once each router resolved
// req.viewer; the per-address limits below stay.
// req.viewer; the per-address limits below stay.
- ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
+ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+ ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -127,7 +131,7 @@ function createApp(opts = {}) {
@@ -127,7 +131,7 @@ function createApp(opts = {}) {
// GET /release.json (ADR-016) and POST /release-metrics (release_client_updates_total in /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (release_client_updates_total in /metrics).
define(machine, 'get', '/release.json', 'releaseInfo', release.handler);
define(machine, 'get', '/release.json', 'releaseInfo', release.handler);
define(machine, 'post', '/release-metrics', 'releaseMetrics', release.collect(metrics.registry));
define(machine, 'post', '/release-metrics', 'releaseMetrics', release.collect(metrics.registry));
- const readiness = createTradeReadiness({ store, auth: ctx.auth, outbox: ctx.outbox, sync: ctx.sync, freshness: ctx.freshness, release: release.release });
+ const readiness = createTradeReadiness({ store, auth: ctx.auth, outbox: ctx.outbox, sync: ctx.sync, freshness: ctx.freshness, release: release.release, valkey: ctx.valkey });
define(machine, 'get', '/api/ready', 'readiness', readiness.handler);
define(machine, 'get', '/api/ready', 'readiness', readiness.handler);
app.use(machine);
app.use(machine);
@@ -81,7 +81,7 @@ function createViewerResolver({ auth, config }) {
@@ -81,7 +81,7 @@ function createViewerResolver({ auth, config }) {
const payload = decodeJwtPayload(token);
const payload = decodeJwtPayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (opts.services === false) return ANONYMOUS;
if (opts.services === false) return ANONYMOUS;
- return fromServiceToken(req, token);
+ return await fromServiceToken(req, token);
}
}
}
}
const token = extractToken(req);
const token = extractToken(req);
@@ -34,8 +34,8 @@ function createSourcesClient({ config, fetchImpl = globalThis.fetch }) {
@@ -34,8 +34,8 @@ function createSourcesClient({ config, fetchImpl = globalThis.fetch }) {
return {
return {
enabled,
enabled,
- items: ({ after = 0, limit = 200 } = {}) => get(`/api/v1/items?category=${encodeURIComponent(config.sources.category)}&after=${after}&limit=${limit}&include_removed=1`),
- sources: () => get('/api/v1/sources'),
+ items: async ({ after = 0, limit = 200 } = {}) => await get(`/api/v1/items?category=${encodeURIComponent(config.sources.category)}&after=${after}&limit=${limit}&include_removed=1`),
+ sources: async () => await get('/api/v1/sources'),
};
};
}
}
@@ -35,6 +35,10 @@ function load(env = process.env) {
@@ -35,6 +35,10 @@ function load(env = process.env) {
hour: Math.max(1, int(env.TRADE_LIMITS_HOUR, 3000)),
hour: Math.max(1, int(env.TRADE_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:trade:' },
+ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.TRADE_DB_PATH || './data/trade.db',
dbPath: env.TRADE_DB_PATH || './data/trade.db',
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
/**
/**
- * Trade's own SQLite database: created on boot, idempotently. Nothing here is shared with another
+ * Trade'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.
* service.
*
*
* The nine charter tables (roadmap §15.13):
* The nine charter tables (roadmap §15.13):
@@ -27,196 +28,55 @@
@@ -27,196 +28,55 @@
*/
*/
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 { createReviewLog } = require('openvibe-publishing/authorship');
const { createReviewLog } = require('openvibe-publishing/authorship');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS trade_instruments (
- id TEXT PRIMARY KEY, -- ins_<ULID>
- symbol TEXT NOT NULL UNIQUE, -- canonical display ticker, upper case: /i/:symbol
- name TEXT NOT NULL,
- kind TEXT NOT NULL CHECK (kind IN ('equity','fund','index','currency','commodity','crypto','other')),
- exchange TEXT, -- as an editor recorded it; NULL = not recorded
- cik TEXT UNIQUE, -- SEC Central Index Key, 10 digits zero-padded
- currency TEXT, -- ISO 4217 trading currency, when recorded
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
- context_published_revision INTEGER, -- trade_context_revisions.number readers see
- context_published_at INTEGER,
- created_by TEXT, -- usr_… or svc:…
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
-CREATE TABLE IF NOT EXISTS trade_instrument_aliases (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
- kind TEXT NOT NULL CHECK (kind IN ('ticker','cik','name')),
- value TEXT NOT NULL, -- as entered
- normalized TEXT NOT NULL, -- the resolution key (see domain/instruments.js)
- added_by TEXT,
- created_at INTEGER NOT NULL,
- UNIQUE (instrument_id, kind, normalized)
-);
-CREATE UNIQUE INDEX IF NOT EXISTS trade_aliases_unique_key ON trade_instrument_aliases (kind, normalized) WHERE kind IN ('ticker','cik');
-CREATE INDEX IF NOT EXISTS trade_aliases_lookup ON trade_instrument_aliases (kind, normalized);
-
-CREATE TABLE IF NOT EXISTS trade_watchlists (
- id TEXT PRIMARY KEY, -- wl_<ULID>
- owner_subject TEXT NOT NULL, -- usr_…; never shown to anyone else
- name TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- UNIQUE (owner_subject, name)
-);
-
-CREATE TABLE IF NOT EXISTS trade_watchlist_items (
- watchlist_id TEXT NOT NULL REFERENCES trade_watchlists(id) ON DELETE CASCADE,
- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
- note TEXT,
- added_at INTEGER NOT NULL,
- PRIMARY KEY (watchlist_id, instrument_id)
-);
-
-CREATE TABLE IF NOT EXISTS trade_market_observations (
- id TEXT PRIMARY KEY, -- obs_<ULID>
- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
- metric TEXT NOT NULL, -- e.g. price.close, volume, us-gaap:Revenues
- value TEXT NOT NULL, -- the decimal exactly as the source stated it
- value_num REAL NOT NULL, -- the same value, for comparisons only
- unit TEXT NOT NULL, -- USD, shares, USD/share, pure, …
- currency TEXT, -- ISO 4217 when the value is monetary
- period TEXT, -- e.g. 2026-Q2, when the source states one
- observed_at INTEGER NOT NULL, -- when the value was true, per the source
- source_key TEXT NOT NULL, -- OpenVibe.Sources source key
- source_item_id TEXT, -- itm_… when it came from a Sources item
- source_url TEXT,
- source_ref TEXT NOT NULL, -- the source's own identity of this datum
- retrieved_at INTEGER NOT NULL, -- when the source was fetched
- max_age_sec INTEGER, -- the value is stale after this long (NULL: only the source's freshness)
- recorded_by TEXT NOT NULL, -- svc:… (a feed) or usr_… (never inferred)
- recorded_at INTEGER NOT NULL,
- UNIQUE (source_key, source_ref)
-);
-CREATE INDEX IF NOT EXISTS trade_obs_series ON trade_market_observations (instrument_id, metric, observed_at);
-CREATE TRIGGER IF NOT EXISTS trade_obs_no_update BEFORE UPDATE ON trade_market_observations
- BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;
-CREATE TRIGGER IF NOT EXISTS trade_obs_no_delete BEFORE DELETE ON trade_market_observations
- BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;
-
-CREATE TABLE IF NOT EXISTS trade_source_documents (
- id TEXT PRIMARY KEY, -- doc_<ULID>
- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
- source_key TEXT NOT NULL,
- source_item_id TEXT NOT NULL UNIQUE, -- itm_…: Sources owns the item
- source_revision INTEGER NOT NULL,
- kind TEXT NOT NULL CHECK (kind IN ('filing','article','record')),
- form_type TEXT, -- 10-K, 8-K, … when the source states it
- title TEXT,
- filer_name TEXT,
- cik TEXT,
- accession TEXT,
- url TEXT,
- published_at INTEGER, -- the source's own date; NULL if it gave none
- source_updated_at INTEGER,
- retrieved_at INTEGER NOT NULL,
- first_seen_at INTEGER NOT NULL,
- license_note TEXT,
- terms_note TEXT,
- removed_at INTEGER,
- removed_reason TEXT,
- recorded_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS trade_docs_instrument ON trade_source_documents (instrument_id, removed_at, published_at);
-
-CREATE TABLE IF NOT EXISTS trade_alert_rules (
- id TEXT PRIMARY KEY, -- alr_<ULID>
- owner_subject TEXT NOT NULL,
- instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
- kind TEXT NOT NULL CHECK (kind IN ('threshold','filing_type','new_document')),
- metric TEXT,
- operator TEXT CHECK (operator IN ('above','below')),
- threshold TEXT,
- threshold_num REAL,
- unit TEXT,
- currency TEXT,
- form_types TEXT, -- JSON array (filing_type rules)
- armed INTEGER NOT NULL DEFAULT 1, -- threshold rules fire on a crossing, then re-arm
- last_observed_at INTEGER, -- newest observation a threshold rule evaluated
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- CHECK (kind <> 'threshold' OR (metric IS NOT NULL AND operator IS NOT NULL AND threshold IS NOT NULL AND unit IS NOT NULL)),
- CHECK (kind <> 'filing_type' OR form_types IS NOT NULL)
-);
-CREATE INDEX IF NOT EXISTS trade_rules_owner ON trade_alert_rules (owner_subject, status);
-CREATE INDEX IF NOT EXISTS trade_rules_instrument ON trade_alert_rules (instrument_id, status, kind);
-
-CREATE TABLE IF NOT EXISTS trade_alert_deliveries (
- id TEXT PRIMARY KEY, -- ald_<ULID>
- rule_id TEXT NOT NULL REFERENCES trade_alert_rules(id),
- owner_subject TEXT NOT NULL,
- trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('observation','document')),
- trigger_id TEXT NOT NULL,
- event_id TEXT, -- the trade.alert.triggered envelope
- summary TEXT NOT NULL, -- JSON snapshot of what triggered it
- created_at INTEGER NOT NULL,
- UNIQUE (rule_id, trigger_kind, trigger_id)
-);
-CREATE INDEX IF NOT EXISTS trade_deliveries_owner ON trade_alert_deliveries (owner_subject, created_at);
-
-CREATE TABLE IF NOT EXISTS trade_source_status (
- source_key TEXT PRIMARY KEY,
- name TEXT,
- upstream_status TEXT, -- Sources' health status, NULL = never reported
- last_success_at INTEGER, -- last successful fetch (Sources, or the newest retrieval Trade recorded)
- stale_after_sec INTEGER,
- reported_at INTEGER, -- when Sources last told us about it
- stale INTEGER NOT NULL DEFAULT 1, -- as of the last evaluation
- stale_since INTEGER,
- evaluated_at INTEGER,
- terms_note TEXT,
- license_note TEXT
-);
-
-CREATE TABLE IF NOT EXISTS trade_sync_state (
- name TEXT PRIMARY KEY,
- cursor INTEGER NOT NULL DEFAULT 0,
- last_ok_at INTEGER,
- last_error TEXT,
- last_error_at INTEGER,
- counts TEXT NOT NULL DEFAULT '{}'
-);
-`;
+/**
+ * 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.
+ */
+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 trade)');
+ log.warn(`[Trade] 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: 'trade', 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: 'trade-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'trade', registry, log });
+}
/**
/**
- * Open (or create) the database and the package stores on it.
- * opts.now — injectable clock (epoch ms) shared by everything, so tests are deterministic.
+ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms), so tests and replays are
+ * deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).
*/
*/
-function openStore(dbPath, { now = () => Date.now() } = {}) {
- 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);
+function createStore(db, { now = () => Date.now() } = {}) {
return {
return {
db,
db,
now,
now,
revisions: createRevisionStore(db, { prefix: 'trade_context', now }),
revisions: createRevisionStore(db, { prefix: 'trade_context', now }),
reviews: createReviewLog(db, { prefix: 'trade_context', now }),
reviews: createReviewLog(db, { prefix: 'trade_context', now }),
sequencer: createIndexSequencer(db, { prefix: 'trade', now }),
sequencer: createIndexSequencer(db, { prefix: 'trade', now }),
- tx: (fn) => db.transaction(fn)(),
+ tx: async (fn) => await db.tx(() => fn()),
close: () => db.close(),
close: () => db.close(),
};
};
}
}
+/** openDb + createStore. */
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
const CHARTER_TABLES = ['trade_instruments', 'trade_instrument_aliases', 'trade_watchlists', 'trade_watchlist_items',
const CHARTER_TABLES = ['trade_instruments', 'trade_instrument_aliases', 'trade_watchlists', 'trade_watchlist_items',
'trade_market_observations', 'trade_source_documents', 'trade_alert_rules', 'trade_alert_deliveries', 'trade_context_revisions'];
'trade_market_observations', 'trade_source_documents', 'trade_alert_rules', 'trade_alert_deliveries', 'trade_context_revisions'];
-module.exports = { openStore, CHARTER_TABLES };
+module.exports = { openDb, openStore, createStore, MIGRATIONS, CHARTER_TABLES };
@@ -39,16 +39,16 @@ function createAlerts({ store, config, ctx }) {
@@ -39,16 +39,16 @@ function createAlerts({ store, config, ctx }) {
thresholds: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind = 'threshold' AND metric = ? AND unit = ?"),
thresholds: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind = 'threshold' AND metric = ? AND unit = ?"),
docRules: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind IN ('filing_type','new_document')"),
docRules: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind IN ('filing_type','new_document')"),
setState: db.prepare('UPDATE trade_alert_rules SET armed = @armed, last_observed_at = @observed, updated_at = @now WHERE id = @id'),
setState: db.prepare('UPDATE trade_alert_rules SET armed = @armed, last_observed_at = @observed, updated_at = @now WHERE id = @id'),
- deliver: db.prepare(`INSERT OR IGNORE INTO trade_alert_deliveries (id, rule_id, owner_subject, trigger_kind, trigger_id, summary, created_at)
- VALUES (@id, @rule_id, @owner, @kind, @trigger_id, @summary, @now)`),
+ deliver: db.prepare(`INSERT INTO trade_alert_deliveries (id, rule_id, owner_subject, trigger_kind, trigger_id, summary, created_at)
+ VALUES (@id, @rule_id, @owner, @kind, @trigger_id, @summary, @now) ON CONFLICT DO NOTHING`),
setEvent: db.prepare('UPDATE trade_alert_deliveries SET event_id = ? WHERE id = ?'),
setEvent: db.prepare('UPDATE trade_alert_deliveries SET event_id = ? WHERE id = ?'),
deliveries: db.prepare(`SELECT d.*, r.kind AS rule_kind FROM trade_alert_deliveries d JOIN trade_alert_rules r ON r.id = d.rule_id
deliveries: db.prepare(`SELECT d.*, r.kind AS rule_kind FROM trade_alert_deliveries d JOIN trade_alert_rules r ON r.id = d.rule_id
WHERE d.owner_subject = ? ORDER BY d.created_at DESC, d.id DESC LIMIT ?`),
WHERE d.owner_subject = ? ORDER BY d.created_at DESC, d.id DESC LIMIT ?`),
countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_alert_deliveries WHERE rule_id = ?'),
countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_alert_deliveries WHERE rule_id = ?'),
};
};
- function ruleDto(r, instrument) {
- const ins = instrument || ctx.instruments.get(r.instrument_id);
+ async function ruleDto(r, instrument) {
+ const ins = instrument || await ctx.instruments.get(r.instrument_id);
return {
return {
id: r.id, kind: r.kind, status: r.status,
id: r.id, kind: r.kind, status: r.status,
instrument: ins ? { id: ins.id, symbol: ins.symbol, name: ins.name } : { id: r.instrument_id },
instrument: ins ? { id: ins.id, symbol: ins.symbol, name: ins.name } : { id: r.instrument_id },
@@ -59,47 +59,47 @@ function createAlerts({ store, config, ctx }) {
@@ -59,47 +59,47 @@ function createAlerts({ store, config, ctx }) {
};
};
}
}
- function deliver(rule, instrument, kind, trigger, summary, { traceparent } = {}) {
+ async function deliver(rule, instrument, kind, trigger, summary, { traceparent } = {}) {
const id = newId('ald', store.now());
const id = newId('ald', store.now());
- const info = q.deliver.run({ id, rule_id: rule.id, owner: rule.owner_subject, kind, trigger_id: trigger.id, summary: JSON.stringify(summary), now: store.now() });
+ const info = await q.deliver.run({ id, rule_id: rule.id, owner: rule.owner_subject, kind, trigger_id: trigger.id, summary: JSON.stringify(summary), now: store.now() });
if (!info.changes) return null; // already delivered for this (rule, trigger)
if (!info.changes) return null; // already delivered for this (rule, trigger)
- const env = ctx.outbox.emit({
+ const env = await ctx.outbox.emit({
event_type: 'trade.alert.triggered', actor: { type: 'service', id: 'trade' }, visibility: 'subject', priority: 'important',
event_type: 'trade.alert.triggered', actor: { type: 'service', id: 'trade' }, visibility: 'subject', priority: 'important',
subject: { type: 'user', id: rule.owner_subject },
subject: { type: 'user', id: rule.owner_subject },
payload: {
payload: {
delivery_id: id,
delivery_id: id,
- rule: ruleDto(rule, instrument),
+ rule: await ruleDto(rule, instrument),
instrument: { id: instrument.id, symbol: instrument.symbol, name: instrument.name, url: ctx.urls.instrument(instrument) },
instrument: { id: instrument.id, symbol: instrument.symbol, name: instrument.name, url: ctx.urls.instrument(instrument) },
trigger: { kind, id: trigger.id, ...summary },
trigger: { kind, id: trigger.id, ...summary },
disclaimer: DISCLAIMER,
disclaimer: DISCLAIMER,
},
},
}, { traceparent });
}, { traceparent });
- q.setEvent.run(env.event_id, id);
+ await q.setEvent.run(env.event_id, id);
return { id, event_id: env.event_id };
return { id, event_id: env.event_id };
}
}
const api = {
const api = {
KINDS,
KINDS,
DISCLAIMER,
DISCLAIMER,
- get: (id) => q.byId.get(id) || null,
+ get: async (id) => await q.byId.get(id) || null,
dto: ruleDto,
dto: ruleDto,
- forOwner(subject) { return q.forOwner.all(subject).map((r) => ruleDto(r, { id: r.instrument_id, symbol: r.symbol, name: r.instrument_name })); },
+ async forOwner(subject) { return (await Promise.all((await q.forOwner.all(subject)).map(async (r) => await ruleDto(r, { id: r.instrument_id, symbol: r.symbol, name: r.instrument_name })))); },
- deliveries(subject, limit = 50) {
- return q.deliveries.all(subject, Math.min(Math.max(Number(limit) || 50, 1), 200)).map((d) => ({
+ async deliveries(subject, limit = 50) {
+ return (await q.deliveries.all(subject, Math.min(Math.max(Number(limit) || 50, 1), 200))).map((d) => ({
id: d.id, rule_id: d.rule_id, rule_kind: d.rule_kind, trigger: { kind: d.trigger_kind, id: d.trigger_id, ...json(d.summary, {}) },
id: d.id, rule_id: d.rule_id, rule_kind: d.rule_kind, trigger: { kind: d.trigger_kind, id: d.trigger_id, ...json(d.summary, {}) },
event_id: d.event_id, created_at: iso(d.created_at),
event_id: d.event_id, created_at: iso(d.created_at),
}));
}));
},
},
- deliveryCount: (ruleId) => q.countFor.get(ruleId).n,
+ deliveryCount: async (ruleId) => (await q.countFor.get(ruleId)).n,
- create(subject, instrument, input) {
+ async create(subject, instrument, input) {
if (!subject) throw new ApiError(403, 'subject.required', 'Alert rules belong to a person');
if (!subject) throw new ApiError(403, 'subject.required', 'Alert rules belong to a person');
if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
const kind = String(input.kind || '');
const kind = String(input.kind || '');
if (!KINDS.includes(kind)) throw invalid(`kind must be one of ${KINDS.join(', ')}`);
if (!KINDS.includes(kind)) throw invalid(`kind must be one of ${KINDS.join(', ')}`);
- if (q.countOwner.get(subject).n >= config.limits.alertRulesPerSubject) throw new ApiError(429, 'alert.limit', `At most ${config.limits.alertRulesPerSubject} alert rules`);
+ if ((await q.countOwner.get(subject)).n >= config.limits.alertRulesPerSubject) throw new ApiError(429, 'alert.limit', `At most ${config.limits.alertRulesPerSubject} alert rules`);
const row = { id: newId('alr', store.now()), owner: subject, instrument_id: instrument.id, kind, metric: null, operator: null, threshold: null, threshold_num: null, unit: null, currency: null, form_types: null, now: store.now() };
const row = { id: newId('alr', store.now()), owner: subject, instrument_id: instrument.id, kind, metric: null, operator: null, threshold: null, threshold_num: null, unit: null, currency: null, form_types: null, now: store.now() };
if (kind === 'threshold') {
if (kind === 'threshold') {
row.metric = str(input.metric, 80, 'metric', { required: true });
row.metric = str(input.metric, 80, 'metric', { required: true });
@@ -118,29 +118,29 @@ function createAlerts({ store, config, ctx }) {
@@ -118,29 +118,29 @@ function createAlerts({ store, config, ctx }) {
if (!list.length || list.length > 10) throw invalid('form_types must list 1–10 form types, e.g. 10-K, 10-Q, 8-K');
if (!list.length || list.length > 10) throw invalid('form_types must list 1–10 form types, e.g. 10-K, 10-Q, 8-K');
row.form_types = JSON.stringify(list);
row.form_types = JSON.stringify(list);
}
}
- q.insert.run(row);
- return q.byId.get(row.id);
+ await q.insert.run(row);
+ return await q.byId.get(row.id);
},
},
- remove(subject, id) {
- const r = q.byId.get(id);
+ async remove(subject, id) {
+ const r = await q.byId.get(id);
// Someone else's rule is "not found": its existence is not disclosed.
// Someone else's rule is "not found": its existence is not disclosed.
if (!r || r.owner_subject !== subject || r.status !== 'active') throw new ApiError(404, 'alert.not_found', 'No such alert rule');
if (!r || r.owner_subject !== subject || r.status !== 'active') throw new ApiError(404, 'alert.not_found', 'No such alert rule');
- q.remove.run(store.now(), id);
+ await q.remove.run(store.now(), id);
return true;
return true;
},
},
/** Inside the transaction that recorded `obs`. */
/** Inside the transaction that recorded `obs`. */
- onObservation(obs, instrument, opts = {}) {
+ async onObservation(obs, instrument, opts = {}) {
const out = [];
const out = [];
- for (const rule of q.thresholds.all(instrument.id, obs.metric, obs.unit)) {
+ for (const rule of await q.thresholds.all(instrument.id, obs.metric, obs.unit)) {
if (rule.currency && rule.currency !== obs.currency) continue;
if (rule.currency && rule.currency !== obs.currency) continue;
if (rule.created_at > obs.recorded_at) continue;
if (rule.created_at > obs.recorded_at) continue;
if (rule.last_observed_at != null && obs.observed_at <= rule.last_observed_at) continue; // late arrival
if (rule.last_observed_at != null && obs.observed_at <= rule.last_observed_at) continue; // late arrival
const meets = rule.operator === 'above' ? obs.value_num > rule.threshold_num : obs.value_num < rule.threshold_num;
const meets = rule.operator === 'above' ? obs.value_num > rule.threshold_num : obs.value_num < rule.threshold_num;
let armed = rule.armed;
let armed = rule.armed;
if (meets && armed) {
if (meets && armed) {
- const d = deliver(rule, instrument, 'observation', obs, {
+ const d = await deliver(rule, instrument, 'observation', obs, {
metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency,
metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency,
observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), source_key: obs.source_key, source_url: obs.source_url,
observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), source_key: obs.source_key, source_url: obs.source_url,
}, opts);
}, opts);
@@ -149,20 +149,20 @@ function createAlerts({ store, config, ctx }) {
@@ -149,20 +149,20 @@ function createAlerts({ store, config, ctx }) {
} else if (!meets) {
} else if (!meets) {
armed = 1;
armed = 1;
}
}
- q.setState.run({ id: rule.id, armed, observed: obs.observed_at, now: store.now() });
+ await q.setState.run({ id: rule.id, armed, observed: obs.observed_at, now: store.now() });
}
}
return out;
return out;
},
},
/** Inside the transaction that first recorded `doc`. */
/** Inside the transaction that first recorded `doc`. */
- onDocument(doc, instrument, opts = {}) {
+ async onDocument(doc, instrument, opts = {}) {
const out = [];
const out = [];
- for (const rule of q.docRules.all(instrument.id)) {
+ for (const rule of await q.docRules.all(instrument.id)) {
if (rule.kind === 'filing_type') {
if (rule.kind === 'filing_type') {
const types = json(rule.form_types, []);
const types = json(rule.form_types, []);
if (!doc.form_type || !types.includes(doc.form_type)) continue;
if (!doc.form_type || !types.includes(doc.form_type)) continue;
}
}
- const d = deliver(rule, instrument, 'document', doc, {
+ const d = await deliver(rule, instrument, 'document', doc, {
form_type: doc.form_type, title: doc.title, url: doc.url, published_at: iso(doc.published_at),
form_type: doc.form_type, title: doc.title, url: doc.url, published_at: iso(doc.published_at),
retrieved_at: iso(doc.retrieved_at), source_key: doc.source_key,
retrieved_at: iso(doc.retrieved_at), source_key: doc.source_key,
}, opts);
}, opts);
@@ -38,7 +38,7 @@ function createContext({ store, config, ctx }) {
@@ -38,7 +38,7 @@ function createContext({ store, config, ctx }) {
}
}
/** Validate cited refs: they must be this instrument's documents (not removed) or observations. */
/** Validate cited refs: they must be this instrument's documents (not removed) or observations. */
- function resolveCites(instrument, cites) {
+ async function resolveCites(instrument, cites) {
if (cites == null) return [];
if (cites == null) return [];
if (!Array.isArray(cites) || cites.length > 50) throw invalid('cites must be a list of at most 50 { kind, id }');
if (!Array.isArray(cites) || cites.length > 50) throw invalid('cites must be a list of at most 50 { kind, id }');
const seen = new Set();
const seen = new Set();
@@ -47,10 +47,10 @@ function createContext({ store, config, ctx }) {
@@ -47,10 +47,10 @@ function createContext({ store, config, ctx }) {
const kind = c && c.kind;
const kind = c && c.kind;
const id = c && String(c.id || '');
const id = c && String(c.id || '');
if (kind === 'document') {
if (kind === 'document') {
- const d = ctx.documents.get(id);
+ const d = await ctx.documents.get(id);
if (!d || d.instrument_id !== instrument.id || d.removed_at) throw invalid(`document ${id} is not a current document of ${instrument.symbol}`, 'context.bad_citation');
if (!d || d.instrument_id !== instrument.id || d.removed_at) throw invalid(`document ${id} is not a current document of ${instrument.symbol}`, 'context.bad_citation');
} else if (kind === 'observation') {
} else if (kind === 'observation') {
- const o = ctx.observations.get(id);
+ const o = await ctx.observations.get(id);
if (!o || o.instrument_id !== instrument.id) throw invalid(`observation ${id} is not an observation of ${instrument.symbol}`, 'context.bad_citation');
if (!o || o.instrument_id !== instrument.id) throw invalid(`observation ${id} is not an observation of ${instrument.symbol}`, 'context.bad_citation');
} else {
} else {
throw invalid('each citation is { kind: "document" | "observation", id }', 'context.bad_citation');
throw invalid('each citation is { kind: "document" | "observation", id }', 'context.bad_citation');
@@ -61,12 +61,12 @@ function createContext({ store, config, ctx }) {
@@ -61,12 +61,12 @@ function createContext({ store, config, ctx }) {
return out;
return out;
}
}
- function asOf(cites) {
+ async function asOf(cites) {
let t = null;
let t = null;
for (const c of cites) {
for (const c of cites) {
let v = null;
let v = null;
- if (c.kind === 'observation') { const o = ctx.observations.get(c.id); v = o && o.observed_at; }
- else { const d = ctx.documents.get(c.id); v = d && (d.published_at || d.retrieved_at); }
+ if (c.kind === 'observation') { const o = await ctx.observations.get(c.id); v = o && o.observed_at; }
+ else { const d = await ctx.documents.get(c.id); v = d && (d.published_at || d.retrieved_at); }
if (v != null && (t == null || v > t)) t = v;
if (v != null && (t == null || v > t)) t = v;
}
}
return iso(t);
return iso(t);
@@ -78,15 +78,15 @@ function createContext({ store, config, ctx }) {
@@ -78,15 +78,15 @@ function createContext({ store, config, ctx }) {
return false;
return false;
}
}
- function create(instrument, { content, fields, rec, author, message, expectedRevision }) {
- const head = store.revisions.headNumber(instrument.id);
+ async function create(instrument, { content, fields, rec, author, message, expectedRevision }) {
+ const head = await store.revisions.headNumber(instrument.id);
const expected = expectedRevision == null ? head : Number(expectedRevision);
const expected = expectedRevision == null ? head : Number(expectedRevision);
if (!Number.isInteger(expected) || expected < 0) throw invalid('expected_revision must be a revision number');
if (!Number.isInteger(expected) || expected < 0) throw invalid('expected_revision must be a revision number');
- return store.revisions.create({ entityId: instrument.id, expectedRevision: expected, content, fields, meta: { authorship: rec }, author, message, allowUnchanged: true }).revision;
+ return (await store.revisions.create({ entityId: instrument.id, expectedRevision: expected, content, fields, meta: { authorship: rec }, author, message, allowUnchanged: true })).revision;
}
}
/** AI output → markdown. Citations are indices into input_sources (what the AI was given). */
/** AI output → markdown. Citations are indices into input_sources (what the AI was given). */
- function fromAiOutput(instrument, body) {
+ async function fromAiOutput(instrument, body) {
const wf = body.workflow || {};
const wf = body.workflow || {};
if (wf.id !== WORKFLOW) throw invalid(`AI context must come from the ${WORKFLOW} workflow`, 'context.wrong_workflow');
if (wf.id !== WORKFLOW) throw invalid(`AI context must come from the ${WORKFLOW} workflow`, 'context.wrong_workflow');
const runId = wf.run_id || wf.runId;
const runId = wf.run_id || wf.runId;
@@ -131,17 +131,17 @@ function createContext({ store, config, ctx }) {
@@ -131,17 +131,17 @@ function createContext({ store, config, ctx }) {
stubProvider: body.stub_provider === true,
stubProvider: body.stub_provider === true,
source: { label: `${instrument.symbol} documents and observations on OpenVibe.Trade` },
source: { label: `${instrument.symbol} documents and observations on OpenVibe.Trade` },
});
});
- resolveCites(instrument, refs); // every source the model was given must exist
- return { content: lines.join('\n'), cites: resolveCites(instrument, all), rec, gaps };
+ await resolveCites(instrument, refs); // every source the model was given must exist
+ return { content: lines.join('\n'), cites: await resolveCites(instrument, all), rec, gaps };
}
}
const api = {
const api = {
WORKFLOW,
WORKFLOW,
isEditorSubject,
isEditorSubject,
- head: (instrument) => store.revisions.head(instrument.id),
- revision: (instrument, n) => store.revisions.get(instrument.id, n),
- revisions: (instrument, opts) => store.revisions.list(instrument.id, opts),
+ head: async (instrument) => await store.revisions.head(instrument.id),
+ revision: async (instrument, n) => await store.revisions.get(instrument.id, n),
+ revisions: async (instrument, opts) => await store.revisions.list(instrument.id, opts),
/**
/**
* A new context revision. Editors: { body, cites?, expected_revision?, publish? }.
* A new context revision. Editors: { body, cites?, expected_revision?, publish? }.
@@ -149,13 +149,13 @@ function createContext({ store, config, ctx }) {
@@ -149,13 +149,13 @@ function createContext({ store, config, ctx }) {
* input_sources: [{ source_type, source_id }], output: { summary, observations, gaps, citations },
* input_sources: [{ source_type, source_id }], output: { summary, observations, gaps, citations },
* stub_provider? } — always a draft.
* stub_provider? } — always a draft.
*/
*/
- propose(viewer, instrument, body = {}) {
+ async propose(viewer, instrument, body = {}) {
if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
if (viewer.kind === 'service' && viewer.origin === 'ai') {
if (viewer.kind === 'service' && viewer.origin === 'ai') {
- const { content, cites, rec, gaps } = fromAiOutput(instrument, body);
+ const { content, cites, rec, gaps } = await fromAiOutput(instrument, body);
refuseAdvice(content);
refuseAdvice(content);
- const fields = { as_of: asOf(cites), cites, origin: 'ai', gaps };
- const revision = store.tx(() => create(instrument, { content, fields, rec, author: viewer.service, message: 'AI draft (needs a person\'s review)' }));
+ const fields = { as_of: await asOf(cites), cites, origin: 'ai', gaps };
+ const revision = await store.tx(async () => await create(instrument, { content, fields, rec, author: viewer.service, message: 'AI draft (needs a person\'s review)' }));
return { revision, published: false };
return { revision, published: false };
}
}
if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors write context');
if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors write context');
@@ -163,13 +163,13 @@ function createContext({ store, config, ctx }) {
@@ -163,13 +163,13 @@ function createContext({ store, config, ctx }) {
if (!text) throw invalid('body is required');
if (!text) throw invalid('body is required');
if (text.length > MAX_BODY) throw invalid(`body is longer than ${MAX_BODY} characters`);
if (text.length > MAX_BODY) throw invalid(`body is longer than ${MAX_BODY} characters`);
refuseAdvice(text);
refuseAdvice(text);
- const cites = resolveCites(instrument, body.cites);
+ const cites = await resolveCites(instrument, body.cites);
const rec = authorship.record({ mode: 'human', authors: [viewer.subject] });
const rec = authorship.record({ mode: 'human', authors: [viewer.subject] });
- const fields = { as_of: asOf(cites), cites, origin: 'editor' };
- return store.tx(() => {
- const revision = create(instrument, { content: text, fields, rec, author: viewer.subject, message: body.message ? String(body.message).slice(0, 300) : null, expectedRevision: body.expected_revision });
+ const fields = { as_of: await asOf(cites), cites, origin: 'editor' };
+ return await store.tx(async () => {
+ const revision = await create(instrument, { content: text, fields, rec, author: viewer.subject, message: body.message ? String(body.message).slice(0, 300) : null, expectedRevision: body.expected_revision });
if (body.publish === true || body.publish === 'on' || body.publish === '1') {
if (body.publish === true || body.publish === 'on' || body.publish === '1') {
- api.publish(instrument, revision.number);
+ await api.publish(instrument, revision.number);
return { revision, published: true };
return { revision, published: true };
}
}
return { revision, published: false };
return { revision, published: false };
@@ -177,64 +177,64 @@ function createContext({ store, config, ctx }) {
@@ -177,64 +177,64 @@ function createContext({ store, config, ctx }) {
},
},
/** A person's review. Approving an AI draft publishes it. */
/** A person's review. Approving an AI draft publishes it. */
- review(viewer, instrument, number, { decision, note } = {}) {
+ async review(viewer, instrument, number, { decision, note } = {}) {
if (viewer.kind !== 'user' || !viewer.editor || !viewer.subject) throw new ApiError(403, 'review.forbidden', 'Only a person on the editor list can review context');
if (viewer.kind !== 'user' || !viewer.editor || !viewer.subject) throw new ApiError(403, 'review.forbidden', 'Only a person on the editor list can review context');
- const rev = store.revisions.get(instrument.id, Number(number));
+ const rev = await store.revisions.get(instrument.id, Number(number));
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');
- return store.tx(() => {
- const review = store.reviews.record({ entityId: instrument.id, revision: rev.number, reviewer: viewer.subject, decision, note });
+ return await store.tx(async () => {
+ const review = await store.reviews.record({ entityId: instrument.id, revision: rev.number, reviewer: viewer.subject, decision, note });
let published = false;
let published = false;
- if (decision === 'approved') { api.publish(instrument, rev.number); published = true; }
+ if (decision === 'approved') { await api.publish(instrument, rev.number); published = true; }
return { review, published };
return { review, published };
});
});
},
},
- publish(instrument, number) {
- const rev = store.revisions.get(instrument.id, Number(number));
+ async publish(instrument, number) {
+ const rev = await store.revisions.get(instrument.id, Number(number));
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');
const rec = rev.meta.authorship;
const rec = rev.meta.authorship;
- const ok = authorship.canPublish(rec, store.reviews.latest(instrument.id, rev.number));
+ const ok = authorship.canPublish(rec, await store.reviews.latest(instrument.id, rev.number));
if (!ok.ok) throw new ApiError(409, 'context.review_required', 'AI-generated context needs an approving review by a person before it is published');
if (!ok.ok) throw new ApiError(409, 'context.review_required', 'AI-generated context needs an approving review by a person before it is published');
- return store.tx(() => {
- setPublished.run(rev.number, store.now(), store.now(), instrument.id);
- ctx.indexing.refresh(ctx.instruments.get(instrument.id));
+ return await store.tx(async () => {
+ await setPublished.run(rev.number, store.now(), store.now(), instrument.id);
+ await ctx.indexing.refresh(await ctx.instruments.get(instrument.id));
return rev;
return rev;
});
});
},
},
- retract(viewer, instrument) {
+ async retract(viewer, instrument) {
if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors retract context');
if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors retract context');
- return store.tx(() => {
- setPublished.run(null, null, store.now(), instrument.id);
- ctx.indexing.refresh(ctx.instruments.get(instrument.id));
+ return await store.tx(async () => {
+ await setPublished.run(null, null, store.now(), instrument.id);
+ await ctx.indexing.refresh(await ctx.instruments.get(instrument.id));
return true;
return true;
});
});
},
},
/** The published revision's view, or null. */
/** The published revision's view, or null. */
- published(instrument) {
+ async published(instrument) {
if (!instrument.context_published_revision) return null;
if (!instrument.context_published_revision) return null;
- const rev = store.revisions.get(instrument.id, instrument.context_published_revision);
- return rev ? api.view(instrument, rev) : null;
+ const rev = await store.revisions.get(instrument.id, instrument.context_published_revision);
+ return rev ? await api.view(instrument, rev) : null;
},
},
/** Revisions after the published one (drafts awaiting review or publication). */
/** Revisions after the published one (drafts awaiting review or publication). */
- pending(instrument) {
+ async pending(instrument) {
const published = instrument.context_published_revision || 0;
const published = instrument.context_published_revision || 0;
- return store.revisions.list(instrument.id, { limit: 20 }).filter((r) => r.number > published).map((r) => api.view(instrument, r));
+ return (await Promise.all((await store.revisions.list(instrument.id, { limit: 20 })).filter((r) => r.number > published).map(async (r) => await api.view(instrument, r))));
},
},
- view(instrument, rev) {
+ async view(instrument, rev) {
const rec = rev.meta.authorship || null;
const rec = rev.meta.authorship || null;
- const review = store.reviews.latest(instrument.id, rev.number);
- const cites = (rev.fields.cites || []).map((c) => {
+ const review = await store.reviews.latest(instrument.id, rev.number);
+ const cites = (await Promise.all((rev.fields.cites || []).map(async (c) => {
if (c.kind === 'observation') {
if (c.kind === 'observation') {
- const o = ctx.observations.get(c.id);
- return o ? { kind: c.kind, id: c.id, observation: ctx.observations.dto(o) } : { kind: c.kind, id: c.id, missing: true };
+ const o = await ctx.observations.get(c.id);
+ return o ? { kind: c.kind, id: c.id, observation: await ctx.observations.dto(o) } : { kind: c.kind, id: c.id, missing: true };
}
}
- const d = ctx.documents.get(c.id);
+ const d = await ctx.documents.get(c.id);
return d && !d.removed_at ? { kind: c.kind, id: c.id, document: ctx.documents.dto(d) } : { kind: c.kind, id: c.id, missing: true };
return d && !d.removed_at ? { kind: c.kind, id: c.id, document: ctx.documents.dto(d) } : { kind: c.kind, id: c.id, missing: true };
- });
+ })));
return {
return {
revision: rev.number,
revision: rev.number,
published: instrument.context_published_revision === rev.number,
published: instrument.context_published_revision === rev.number,
@@ -254,10 +254,10 @@ function createContext({ store, config, ctx }) {
@@ -254,10 +254,10 @@ function createContext({ store, config, ctx }) {
},
},
/** The input OpenVibe.AI's trade.summarize_market_context expects, numbered sources included. */
/** The input OpenVibe.AI's trade.summarize_market_context expects, numbered sources included. */
- input(instrument) {
+ async input(instrument) {
const sources = [];
const sources = [];
- for (const o of ctx.observations.latest(instrument)) {
- const dto = ctx.observations.dto(o);
+ for (const o of await ctx.observations.latest(instrument)) {
+ const dto = await ctx.observations.dto(o);
sources.push({
sources.push({
source_type: 'trade.observation', source_id: o.id, url: o.source_url || undefined,
source_type: 'trade.observation', source_id: o.id, url: o.source_url || undefined,
title: `${o.metric} of ${instrument.symbol}`,
title: `${o.metric} of ${instrument.symbol}`,
@@ -266,8 +266,8 @@ function createContext({ store, config, ctx }) {
@@ -266,8 +266,8 @@ function createContext({ store, config, ctx }) {
provenance: { source_key: o.source_key, stale: dto.freshness.stale, stale_since: dto.freshness.stale_since },
provenance: { source_key: o.source_key, stale: dto.freshness.stale, stale_since: dto.freshness.stale_since },
});
});
}
}
- for (const d of ctx.documents.forInstrument(instrument, { limit: 20 })) {
- const fresh = ctx.freshness.view(d.source_key);
+ for (const d of await ctx.documents.forInstrument(instrument, { limit: 20 })) {
+ const fresh = await ctx.freshness.view(d.source_key);
sources.push({
sources.push({
source_type: 'trade.document', source_id: d.id, url: d.url || undefined, title: d.title || undefined,
source_type: 'trade.document', source_id: d.id, url: d.url || undefined, title: d.title || undefined,
published_at: iso(d.published_at) || undefined, retrieved_at: iso(d.retrieved_at),
published_at: iso(d.published_at) || undefined, retrieved_at: iso(d.retrieved_at),
@@ -276,7 +276,7 @@ function createContext({ store, config, ctx }) {
@@ -276,7 +276,7 @@ function createContext({ store, config, ctx }) {
});
});
}
}
const clean = sources.map((s) => JSON.parse(JSON.stringify(s)));
const clean = sources.map((s) => JSON.parse(JSON.stringify(s)));
- const windows = [...new Set(clean.map((s) => ctx.freshness.view(s.provenance.source_key).stale_after_sec))];
+ const windows = [...new Set((await Promise.all(clean.map(async (s) => (await ctx.freshness.view(s.provenance.source_key)).stale_after_sec))))];
const hours = Math.max(1, Math.ceil((windows.length ? Math.min(...windows) : config.freshness.defaultStaleAfterSec) / 3600));
const hours = Math.max(1, Math.ceil((windows.length ? Math.min(...windows) : config.freshness.defaultStaleAfterSec) / 3600));
return {
return {
workflow: WORKFLOW,
workflow: WORKFLOW,
@@ -33,40 +33,40 @@ function createDocuments({ store, ctx }) {
@@ -33,40 +33,40 @@ function createDocuments({ store, ctx }) {
};
};
const api = {
const api = {
- get: (id) => q.byId.get(id) || null,
- byItem: (itemId) => q.byItem.get(itemId) || null,
- forInstrument: (instrument, { limit = 50, offset = 0 } = {}) => q.forInstrument.all(instrument.id, limit, offset),
- count: (instrument) => q.countFor.get(instrument.id).n,
- recent: (limit = 50) => q.recent.all(limit),
+ get: async (id) => await q.byId.get(id) || null,
+ byItem: async (itemId) => await q.byItem.get(itemId) || null,
+ forInstrument: async (instrument, { limit = 50, offset = 0 } = {}) => await q.forInstrument.all(instrument.id, limit, offset),
+ count: async (instrument) => (await q.countFor.get(instrument.id)).n,
+ recent: async (limit = 50) => await q.recent.all(limit),
/** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */
/** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */
- upsert(doc, instrument, { traceparent } = {}) {
- return store.tx(() => {
- const existing = q.byItem.get(doc.source_item_id);
+ async upsert(doc, instrument, { traceparent } = {}) {
+ return await store.tx(async () => {
+ const existing = await q.byItem.get(doc.source_item_id);
if (existing) {
if (existing) {
if (existing.instrument_id !== instrument.id) return { document: existing, created: false, changed: false };
if (existing.instrument_id !== instrument.id) return { document: existing, created: false, changed: false };
if (doc.source_revision > existing.source_revision) {
if (doc.source_revision > existing.source_revision) {
- q.revise.run({ ...doc, id: existing.id, now: store.now() });
+ await q.revise.run({ ...doc, id: existing.id, now: store.now() });
} else {
} else {
- q.touch.run({ id: existing.id, retrieved_at: doc.retrieved_at, now: store.now() });
+ await q.touch.run({ id: existing.id, retrieved_at: doc.retrieved_at, now: store.now() });
}
}
- ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);
- return { document: q.byId.get(existing.id), created: false, changed: doc.source_revision > existing.source_revision };
+ await ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);
+ return { document: await q.byId.get(existing.id), created: false, changed: doc.source_revision > existing.source_revision };
}
}
const id = newId('doc', store.now());
const id = newId('doc', store.now());
- q.insert.run({ ...doc, id, instrument_id: instrument.id, now: store.now() });
- const row = q.byId.get(id);
- ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);
- ctx.alerts.onDocument(row, instrument, { traceparent });
+ await q.insert.run({ ...doc, id, instrument_id: instrument.id, now: store.now() });
+ const row = await q.byId.get(id);
+ await ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);
+ await ctx.alerts.onDocument(row, instrument, { traceparent });
return { document: row, created: true, changed: true };
return { document: row, created: true, changed: true };
});
});
},
},
/** Sources removed the item: hide the document (the row stays as the record). */
/** Sources removed the item: hide the document (the row stays as the record). */
- remove(itemId, { at, reason }) {
- const row = q.byItem.get(itemId);
+ async remove(itemId, { at, reason }) {
+ const row = await q.byItem.get(itemId);
if (!row) return false;
if (!row) return false;
- return q.remove.run({ id: row.id, at: at || store.now(), reason: String(reason || 'removed at the source').slice(0, 500), now: store.now() }).changes > 0;
+ return (await q.remove.run({ id: row.id, at: at || store.now(), reason: String(reason || 'removed at the source').slice(0, 500), now: store.now() })).changes > 0;
},
},
dto(d) {
dto(d) {
@@ -25,9 +25,9 @@ function createFreshness({ store, config, outbox }) {
@@ -25,9 +25,9 @@ function createFreshness({ store, config, outbox }) {
const q = {
const q = {
get: db.prepare('SELECT * FROM trade_source_status WHERE source_key = ?'),
get: db.prepare('SELECT * FROM trade_source_status WHERE source_key = ?'),
all: db.prepare('SELECT * FROM trade_source_status ORDER BY source_key'),
all: db.prepare('SELECT * FROM trade_source_status ORDER BY source_key'),
- ensure: db.prepare(`INSERT OR IGNORE INTO trade_source_status (source_key, stale, stale_since, evaluated_at) VALUES (?, 1, ?, NULL)`),
+ ensure: db.prepare(`INSERT INTO trade_source_status (source_key, stale, stale_since, evaluated_at) VALUES (?, 1, ?, NULL) ON CONFLICT DO NOTHING`),
report: db.prepare(`UPDATE trade_source_status SET name = COALESCE(@name, name), upstream_status = @status,
report: db.prepare(`UPDATE trade_source_status SET name = COALESCE(@name, name), upstream_status = @status,
- last_success_at = CASE WHEN @last IS NULL THEN last_success_at WHEN last_success_at IS NULL OR @last > last_success_at THEN @last ELSE last_success_at END,
+ last_success_at = CASE WHEN @last::bigint IS NULL THEN last_success_at WHEN last_success_at IS NULL OR @last > last_success_at THEN @last ELSE last_success_at END,
stale_after_sec = COALESCE(@window, stale_after_sec), reported_at = @now,
stale_after_sec = COALESCE(@window, stale_after_sec), reported_at = @now,
terms_note = COALESCE(@terms, terms_note), license_note = COALESCE(@license, license_note)
terms_note = COALESCE(@terms, terms_note), license_note = COALESCE(@license, license_note)
WHERE source_key = @key`),
WHERE source_key = @key`),
@@ -44,8 +44,8 @@ function createFreshness({ store, config, outbox }) {
@@ -44,8 +44,8 @@ function createFreshness({ store, config, outbox }) {
return { known: true, stale: now > until, staleSince: now > until ? until : null, window, lastSuccessAt: row.last_success_at };
return { known: true, stale: now > until, staleSince: now > until ? until : null, window, lastSuccessAt: row.last_success_at };
}
}
- function view(key, now = store.now()) {
- const row = q.get.get(key) || null;
+ async function view(key, now = store.now()) {
+ const row = await q.get.get(key) || null;
const v = verdict(row, now);
const v = verdict(row, now);
return {
return {
key,
key,
@@ -63,21 +63,21 @@ function createFreshness({ store, config, outbox }) {
@@ -63,21 +63,21 @@ function createFreshness({ store, config, outbox }) {
};
};
}
}
- function ensure(key) { q.ensure.run(key, store.now()); }
+ async function ensure(key) { await q.ensure.run(key, store.now()); }
/** Emit on a transition only. Inside the caller's transaction (or its own). */
/** Emit on a transition only. Inside the caller's transaction (or its own). */
- function evaluate(key) {
- return store.tx(() => {
- const row = q.get.get(key);
+ async function evaluate(key) {
+ return await store.tx(async () => {
+ const row = await q.get.get(key);
if (!row) return null;
if (!row) return null;
const v = verdict(row, store.now());
const v = verdict(row, store.now());
const first = row.evaluated_at == null;
const first = row.evaluated_at == null;
const was = Boolean(row.stale);
const was = Boolean(row.stale);
const since = v.stale ? (v.staleSince != null ? v.staleSince : (row.stale_since || store.now())) : null;
const since = v.stale ? (v.staleSince != null ? v.staleSince : (row.stale_since || store.now())) : null;
- q.mark.run({ key, stale: v.stale ? 1 : 0, since, now: store.now() });
+ await q.mark.run({ key, stale: v.stale ? 1 : 0, since, now: store.now() });
// First sight: a fresh source is not a recovery; a stale one is reported once.
// First sight: a fresh source is not a recovery; a stale one is reported once.
if (first ? !v.stale : v.stale === was) return null;
if (first ? !v.stale : v.stale === was) return null;
- const envelope = outbox.emit({
+ const envelope = await outbox.emit({
event_type: v.stale ? 'trade.source.stale' : 'trade.source.recovered',
event_type: v.stale ? 'trade.source.stale' : 'trade.source.recovered',
actor: { type: 'service', id: 'trade' }, visibility: 'public', priority: 'low',
actor: { type: 'service', id: 'trade' }, visibility: 'public', priority: 'low',
subject: { type: 'source', id: key },
subject: { type: 'source', id: key },
@@ -95,23 +95,23 @@ function createFreshness({ store, config, outbox }) {
@@ -95,23 +95,23 @@ function createFreshness({ store, config, outbox }) {
view,
view,
ensure,
ensure,
evaluate,
evaluate,
- evaluateAll() { return q.all.all().map((r) => evaluate(r.source_key)).filter(Boolean); },
- all(now = store.now()) { return q.all.all().map((r) => view(r.source_key, now)); },
+ async evaluateAll() { return (await Promise.all((await q.all.all()).map(async (r) => await evaluate(r.source_key)))).filter(Boolean); },
+ async all(now = store.now()) { return (await Promise.all((await q.all.all()).map(async (r) => await view(r.source_key, now)))); },
/** What Sources says about a source (its health block). Evaluates the transition. */
/** What Sources says about a source (its health block). Evaluates the transition. */
- report(key, { name = null, status = null, lastSuccessAt = null, staleAfterSec = null, termsNote = null, licenseNote = null } = {}) {
- return store.tx(() => {
- ensure(key);
- q.report.run({ key, name, status, last: lastSuccessAt, window: staleAfterSec, now: store.now(), terms: termsNote, license: licenseNote });
- return evaluate(key);
+ async report(key, { name = null, status = null, lastSuccessAt = null, staleAfterSec = null, termsNote = null, licenseNote = null } = {}) {
+ return await store.tx(async () => {
+ await ensure(key);
+ await q.report.run({ key, name, status, last: lastSuccessAt, window: staleAfterSec, now: store.now(), terms: termsNote, license: licenseNote });
+ return await evaluate(key);
});
});
},
},
/** A datum retrieved at `at` proves a successful fetch at that time. */
/** A datum retrieved at `at` proves a successful fetch at that time. */
- noteRetrieval(key, at) {
- ensure(key);
- q.retrieval.run({ key, at });
- return evaluate(key);
+ async noteRetrieval(key, at) {
+ await ensure(key);
+ await q.retrieval.run({ key, at });
+ return await evaluate(key);
},
},
};
};
}
}
@@ -30,15 +30,15 @@ function createIndexing({ store, config, ctx }) {
@@ -30,15 +30,15 @@ function createIndexing({ store, config, ctx }) {
const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL
const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL
ORDER BY observed_at DESC LIMIT 1`);
ORDER BY observed_at DESC LIMIT 1`);
- function publishedContext(instrument) {
+ async function publishedContext(instrument) {
if (!instrument.context_published_revision) return null;
if (!instrument.context_published_revision) return null;
- return store.revisions.get(instrument.id, instrument.context_published_revision);
+ return await store.revisions.get(instrument.id, instrument.context_published_revision);
}
}
- function decide(instrument, now = store.now()) {
- const rev = publishedContext(instrument);
+ async function decide(instrument, now = store.now()) {
+ const rev = await publishedContext(instrument);
const rec = rev ? rev.meta.authorship : null;
const rec = rev ? rev.meta.authorship : null;
- const review = rev ? store.reviews.latest(instrument.id, rev.number) : null;
+ const review = rev ? await store.reviews.latest(instrument.id, rev.number) : null;
const reviewedContext = Boolean(rec && authorship.canPublish(rec, review).ok);
const reviewedContext = Boolean(rec && authorship.canPublish(rec, review).ok);
const facts = {
const facts = {
state: instrument.status === 'active' ? 'published' : 'unpublished',
state: instrument.status === 'active' ? 'published' : 'unpublished',
@@ -49,17 +49,17 @@ function createIndexing({ store, config, ctx }) {
@@ -49,17 +49,17 @@ function createIndexing({ store, config, ctx }) {
sensitiveReviewed: reviewedContext,
sensitiveReviewed: reviewedContext,
};
};
if (rec) Object.assign(facts, authorship.gateFacts(rec, review));
if (rec) Object.assign(facts, authorship.gateFacts(rec, review));
- const m = latestMonetary.get(instrument.id);
+ const m = await latestMonetary.get(instrument.id);
if (m) facts.price = { observedAt: m.observed_at };
if (m) facts.price = { observedAt: m.observed_at };
return seo.evaluate(facts, { policy: { minWords: config.gate.minWords, priceMaxAgeMs: config.freshness.priceMaxAgeMs }, now });
return seo.evaluate(facts, { policy: { minWords: config.gate.minWords, priceMaxAgeMs: config.freshness.priceMaxAgeMs }, now });
}
}
- function document(instrument, decision) {
- const rev = publishedContext(instrument);
+ async function document(instrument, decision) {
+ const rev = await publishedContext(instrument);
const identity = { owner: OWNER, type: 'instrument', id: instrument.id, revision: 0 };
const identity = { owner: OWNER, type: 'instrument', id: instrument.id, revision: 0 };
if (!decision.listable || instrument.status !== 'active') return hooks.tombstone(identity);
if (!decision.listable || instrument.status !== 'active') return hooks.tombstone(identity);
const rec = rev ? rev.meta.authorship : null;
const rec = rev ? rev.meta.authorship : null;
- const aliases = ctx.instruments.aliases(instrument).map((a) => a.value);
+ const aliases = (await ctx.instruments.aliases(instrument)).map((a) => a.value);
return hooks.buildIndexDocument({
return hooks.buildIndexDocument({
...identity,
...identity,
state: 'published',
state: 'published',
@@ -79,22 +79,22 @@ function createIndexing({ store, config, ctx }) {
@@ -79,22 +79,22 @@ function createIndexing({ store, config, ctx }) {
}
}
/** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
/** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
- function refresh(instrument, { traceparent } = {}) {
+ async function refresh(instrument, { traceparent } = {}) {
if (!instrument) return null;
if (!instrument) return null;
- const decision = decide(instrument);
- const doc = document(instrument, decision);
- const prev = store.sequencer.current(OWNER, 'instrument', instrument.id);
+ const decision = await decide(instrument);
+ const doc = await document(instrument, decision);
+ const prev = await store.sequencer.current(OWNER, 'instrument', instrument.id);
if (doc.deleted && prev == null) return null;
if (doc.deleted && prev == null) return null;
- const stamped = store.sequencer.stamp(doc);
+ const stamped = await store.sequencer.stamp(store.db, doc);
if (prev != null && stamped.revision === prev) return null;
if (prev != null && stamped.revision === prev) return null;
- return ctx.outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
+ return await ctx.outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
}
}
return {
return {
OWNER,
OWNER,
decide,
decide,
refresh,
refresh,
- refreshAll: () => store.tx(() => ctx.instruments.active().map((i) => refresh(i)).filter(Boolean)),
+ refreshAll: async () => await store.tx(async () => (await Promise.all((await ctx.instruments.active()).map(async (i) => await refresh(i)))).filter(Boolean)),
};
};
}
}
@@ -62,8 +62,8 @@ function createInstruments({ store }) {
@@ -62,8 +62,8 @@ function createInstruments({ store }) {
aliasesOf: db.prepare('SELECT kind, value, normalized, created_at FROM trade_instrument_aliases WHERE instrument_id = ? ORDER BY kind, normalized'),
aliasesOf: db.prepare('SELECT kind, value, normalized, created_at FROM trade_instrument_aliases WHERE instrument_id = ? ORDER BY kind, normalized'),
insert: db.prepare(`INSERT INTO trade_instruments (id, symbol, name, kind, exchange, cik, currency, status, created_by, created_at, updated_at)
insert: db.prepare(`INSERT INTO trade_instruments (id, symbol, name, kind, exchange, cik, currency, status, created_by, created_at, updated_at)
VALUES (@id, @symbol, @name, @kind, @exchange, @cik, @currency, 'active', @by, @now, @now)`),
VALUES (@id, @symbol, @name, @kind, @exchange, @cik, @currency, 'active', @by, @now, @now)`),
- insertAlias: db.prepare(`INSERT OR IGNORE INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)
- VALUES (?, ?, ?, ?, ?, ?)`),
+ insertAlias: db.prepare(`INSERT INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),
count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),
count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),
page: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol LIMIT ? OFFSET ?"),
page: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol LIMIT ? OFFSET ?"),
active: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol"),
active: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol"),
@@ -93,11 +93,13 @@ function createInstruments({ store }) {
@@ -93,11 +93,13 @@ function createInstruments({ store }) {
return out;
return out;
}
}
- function conflictGuard(fn) {
- try { return fn(); } catch (err) {
- if (err && /UNIQUE constraint failed/.test(err.message)) {
- if (/trade_instruments\.symbol/.test(err.message)) throw new ApiError(409, 'instrument.symbol_taken', 'Another instrument has this symbol');
- if (/trade_instruments\.cik/.test(err.message)) throw new ApiError(409, 'instrument.cik_taken', 'Another instrument has this CIK');
+ async function conflictGuard(fn) {
+ try { return await fn(); } catch (err) {
+ // PostgreSQL unique_violation (23505): err.table and err.detail ("Key (symbol)=(…) already exists.") name it.
+ if (err && err.code === '23505') {
+ const own = err.table === 'trade_instruments';
+ if (own && /\(symbol\)/.test(err.detail || '')) throw new ApiError(409, 'instrument.symbol_taken', 'Another instrument has this symbol');
+ if (own && /\(cik\)/.test(err.detail || '')) throw new ApiError(409, 'instrument.cik_taken', 'Another instrument has this CIK');
throw new ApiError(409, 'alias.taken', 'This ticker or CIK already names another instrument');
throw new ApiError(409, 'alias.taken', 'This ticker or CIK already names another instrument');
}
}
throw err;
throw err;
@@ -108,34 +110,34 @@ function createInstruments({ store }) {
@@ -108,34 +110,34 @@ function createInstruments({ store }) {
KINDS,
KINDS,
normSymbol, normCik, normName,
normSymbol, normCik, normName,
- get: (id) => q.byId.get(id) || null,
- bySymbol: (symbol) => { const s = normSymbol(symbol); return s ? q.bySymbol.get(s) || null : null; },
- aliases: (instrument) => q.aliasesOf.all(instrument.id),
- active: () => q.active.all(),
- page({ limit = 50, offset = 0 } = {}) {
- return { total: q.count.get().n, instruments: q.page.all(limit, offset) };
+ get: async (id) => await q.byId.get(id) || null,
+ bySymbol: async (symbol) => { const s = normSymbol(symbol); return s ? await q.bySymbol.get(s) || null : null; },
+ aliases: async (instrument) => await q.aliasesOf.all(instrument.id),
+ active: async () => await q.active.all(),
+ async page({ limit = 50, offset = 0 } = {}) {
+ return { total: (await q.count.get()).n, instruments: await q.page.all(limit, offset) };
},
},
/** input: { symbol, name, kind?, exchange?, cik?, currency? } → the new row. Aliases for the symbol, CIK and name are added. */
/** input: { symbol, name, kind?, exchange?, cik?, currency? } → the new row. Aliases for the symbol, CIK and name are added. */
- create(input, by) {
+ async create(input, by) {
const symbol = normSymbol(input.symbol);
const symbol = normSymbol(input.symbol);
if (!symbol) throw invalid('symbol must be 1–16 characters of A–Z, 0–9, "." or "-"');
if (!symbol) throw invalid('symbol must be 1–16 characters of A–Z, 0–9, "." or "-"');
const f = fields(input);
const f = fields(input);
const id = newId('ins', store.now());
const id = newId('ins', store.now());
- return conflictGuard(() => store.tx(() => {
+ return await conflictGuard(async () => await store.tx(async () => {
// A ticker alias of another instrument must not be shadowed by a new symbol.
// A ticker alias of another instrument must not be shadowed by a new symbol.
- const holder = q.alias.all('ticker', symbol).find((i) => i.symbol !== symbol);
+ const holder = (await q.alias.all('ticker', symbol)).find((i) => i.symbol !== symbol);
if (holder) throw new ApiError(409, 'alias.taken', `${symbol} is already an alias of ${holder.symbol}`);
if (holder) throw new ApiError(409, 'alias.taken', `${symbol} is already an alias of ${holder.symbol}`);
- q.insert.run({ id, symbol, ...f, by: by || null, now: store.now() });
- q.insertAlias.run(id, 'ticker', symbol, symbol, by || null, store.now());
- if (f.cik) q.insertAlias.run(id, 'cik', f.cik, f.cik, by || null, store.now());
+ await q.insert.run({ id, symbol, ...f, by: by || null, now: store.now() });
+ await q.insertAlias.run(id, 'ticker', symbol, symbol, by || null, store.now());
+ if (f.cik) await q.insertAlias.run(id, 'cik', f.cik, f.cik, by || null, store.now());
const n = normName(f.name);
const n = normName(f.name);
- if (n) q.insertAlias.run(id, 'name', f.name, n, by || null, store.now());
- return q.byId.get(id);
+ if (n) await q.insertAlias.run(id, 'name', f.name, n, by || null, store.now());
+ return await q.byId.get(id);
}));
}));
},
},
- update(instrument, input) {
+ async update(instrument, input) {
const f = fields(input, { partial: true });
const f = fields(input, { partial: true });
if (input.status !== undefined) {
if (input.status !== undefined) {
if (!['active', 'archived'].includes(input.status)) throw invalid('status must be active or archived');
if (!['active', 'archived'].includes(input.status)) throw invalid('status must be active or archived');
@@ -143,28 +145,28 @@ function createInstruments({ store }) {
@@ -143,28 +145,28 @@ function createInstruments({ store }) {
}
}
const keys = Object.keys(f);
const keys = Object.keys(f);
if (!keys.length) return instrument;
if (!keys.length) return instrument;
- return conflictGuard(() => store.tx(() => {
- db.prepare(`UPDATE trade_instruments SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...f, now: store.now(), id: instrument.id });
- if (f.cik) q.insertAlias.run(instrument.id, 'cik', f.cik, f.cik, null, store.now());
- if (f.name) { const n = normName(f.name); if (n) q.insertAlias.run(instrument.id, 'name', f.name, n, null, store.now()); }
- return q.byId.get(instrument.id);
+ return await conflictGuard(async () => await store.tx(async () => {
+ await db.prepare(`UPDATE trade_instruments SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...f, now: store.now(), id: instrument.id });
+ if (f.cik) await q.insertAlias.run(instrument.id, 'cik', f.cik, f.cik, null, store.now());
+ if (f.name) { const n = normName(f.name); if (n) await q.insertAlias.run(instrument.id, 'name', f.name, n, null, store.now()); }
+ return await q.byId.get(instrument.id);
}));
}));
},
},
- addAlias(instrument, kind, value, by) {
+ async addAlias(instrument, kind, value, by) {
if (!NORMALIZE[kind]) throw invalid('alias kind must be ticker, cik or name');
if (!NORMALIZE[kind]) throw invalid('alias kind must be ticker, cik or name');
const raw = str(value, 200, 'alias', { required: true });
const raw = str(value, 200, 'alias', { required: true });
const n = NORMALIZE[kind](raw);
const n = NORMALIZE[kind](raw);
if (!n) throw invalid(`not a valid ${kind}`);
if (!n) throw invalid(`not a valid ${kind}`);
if (kind === 'ticker') {
if (kind === 'ticker') {
- const owner = q.bySymbol.get(n);
+ const owner = await q.bySymbol.get(n);
if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the symbol of ${owner.symbol}`);
if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the symbol of ${owner.symbol}`);
}
}
if (kind === 'cik') {
if (kind === 'cik') {
- const owner = q.byCik.get(n);
+ const owner = await q.byCik.get(n);
if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the CIK of ${owner.symbol}`);
if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the CIK of ${owner.symbol}`);
}
}
- conflictGuard(() => q.insertAlias.run(instrument.id, kind, raw, n, by || null, store.now()));
+ await conflictGuard(async () => await q.insertAlias.run(instrument.id, kind, raw, n, by || null, store.now()));
return { kind, value: raw, normalized: n };
return { kind, value: raw, normalized: n };
},
},
@@ -172,7 +174,7 @@ function createInstruments({ store }) {
@@ -172,7 +174,7 @@ function createInstruments({ store }) {
* → { query, status: 'resolved'|'ambiguous'|'not_found', match: { kind, value } | null,
* → { query, status: 'resolved'|'ambiguous'|'not_found', match: { kind, value } | null,
* instrument | null, candidates: [] }
* instrument | null, candidates: [] }
*/
*/
- resolve(query, { kind = null } = {}) {
+ async resolve(query, { kind = null } = {}) {
const raw = String(query == null ? '' : query).trim().slice(0, 200);
const raw = String(query == null ? '' : query).trim().slice(0, 200);
const out = (status, match, instrument, candidates = []) => ({ query: raw, status, match, instrument: instrument || null, candidates });
const out = (status, match, instrument, candidates = []) => ({ query: raw, status, match, instrument: instrument || null, candidates });
if (!raw) return out('not_found', null, null);
if (!raw) return out('not_found', null, null);
@@ -181,23 +183,23 @@ function createInstruments({ store }) {
@@ -181,23 +183,23 @@ function createInstruments({ store }) {
if (!kind || kind === 'cik') {
if (!kind || kind === 'cik') {
const cik = /^(CIK)?\d{1,10}$/i.test(raw) ? normCik(raw) : null;
const cik = /^(CIK)?\d{1,10}$/i.test(raw) ? normCik(raw) : null;
if (cik) {
if (cik) {
- const hit = q.byCik.get(cik) || q.alias.all('cik', cik)[0];
+ const hit = await q.byCik.get(cik) || (await q.alias.all('cik', cik))[0];
if (hit) return out('resolved', { kind: 'cik', value: cik }, hit);
if (hit) return out('resolved', { kind: 'cik', value: cik }, hit);
}
}
}
}
if (!kind || kind === 'ticker') {
if (!kind || kind === 'ticker') {
const sym = normSymbol(raw);
const sym = normSymbol(raw);
if (sym) {
if (sym) {
- const hit = q.bySymbol.get(sym);
+ const hit = await q.bySymbol.get(sym);
if (hit) return out('resolved', { kind: 'symbol', value: sym }, hit);
if (hit) return out('resolved', { kind: 'symbol', value: sym }, hit);
- const alias = q.alias.all('ticker', sym)[0];
+ const alias = (await q.alias.all('ticker', sym))[0];
if (alias) return out('resolved', { kind: 'ticker', value: sym }, alias);
if (alias) return out('resolved', { kind: 'ticker', value: sym }, alias);
}
}
}
}
if (!kind || kind === 'name') {
if (!kind || kind === 'name') {
const n = normName(raw);
const n = normName(raw);
if (n) {
if (n) {
- const hits = q.alias.all('name', n);
+ const hits = await q.alias.all('name', n);
if (hits.length === 1) return out('resolved', { kind: 'name', value: n }, hits[0]);
if (hits.length === 1) return out('resolved', { kind: 'name', value: n }, hits[0]);
if (hits.length > 1) return out('ambiguous', { kind: 'name', value: n }, null, hits);
if (hits.length > 1) return out('ambiguous', { kind: 'name', value: n }, null, hits);
}
}
@@ -35,10 +35,10 @@ function createObservations({ store, ctx }) {
@@ -35,10 +35,10 @@ function createObservations({ store, ctx }) {
VALUES (@id, @instrument_id, @metric, @value, @value_num, @unit, @currency, @period, @observed_at,
VALUES (@id, @instrument_id, @metric, @value, @value_num, @unit, @currency, @period, @observed_at,
@source_key, @source_item_id, @source_url, @source_ref, @retrieved_at, @max_age_sec, @recorded_by, @recorded_at)`),
@source_key, @source_item_id, @source_url, @source_ref, @retrieved_at, @max_age_sec, @recorded_by, @recorded_at)`),
latest: db.prepare(`SELECT * FROM (
latest: db.prepare(`SELECT * FROM (
- SELECT o.*, ROW_NUMBER() OVER (PARTITION BY metric, unit, IFNULL(currency, '') ORDER BY observed_at DESC, recorded_at DESC, id DESC) AS rn
- FROM trade_market_observations o WHERE instrument_id = ?)
+ SELECT o.*, ROW_NUMBER() OVER (PARTITION BY metric, unit, COALESCE(currency, '') ORDER BY observed_at DESC, recorded_at DESC, id DESC) AS rn
+ FROM trade_market_observations o WHERE instrument_id = ?) latest
WHERE rn = 1 ORDER BY metric, unit`),
WHERE rn = 1 ORDER BY metric, unit`),
- history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ? AND (@metric IS NULL OR metric = @metric)
+ history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = @instrument AND (@metric::text IS NULL OR metric = @metric)
AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),
AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),
count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),
count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),
};
};
@@ -79,48 +79,48 @@ function createObservations({ store, ctx }) {
@@ -79,48 +79,48 @@ function createObservations({ store, ctx }) {
&& (a.currency || null) === (b.currency || null) && a.observed_at === b.observed_at;
&& (a.currency || null) === (b.currency || null) && a.observed_at === b.observed_at;
const api = {
const api = {
- get: (id) => q.byId.get(id) || null,
- count: (instrument) => q.count.get(instrument.id).n,
- latest: (instrument) => q.latest.all(instrument.id),
- history(instrument, { metric = null, before = null, limit = 50 } = {}) {
+ get: async (id) => await q.byId.get(id) || null,
+ count: async (instrument) => (await q.count.get(instrument.id)).n,
+ latest: async (instrument) => await q.latest.all(instrument.id),
+ async history(instrument, { metric = null, before = null, limit = 50 } = {}) {
const b = parseTime(before);
const b = parseTime(before);
- return q.history.all(instrument.id, { metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });
+ return await q.history.all({ instrument: instrument.id, metric: metric == null ? null : metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });
},
},
/**
/**
* → { observation, created }. recordedBy: 'svc:…' or 'usr_…' (who recorded it here).
* → { observation, created }. recordedBy: 'svc:…' or 'usr_…' (who recorded it here).
*/
*/
- record(input, instrument, { recordedBy, traceparent } = {}) {
+ async record(input, instrument, { recordedBy, traceparent } = {}) {
if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
if (!recordedBy) throw new TypeError('recordedBy is required');
if (!recordedBy) throw new TypeError('recordedBy is required');
const row = validate(input || {}, instrument);
const row = validate(input || {}, instrument);
- return store.tx(() => {
- const existing = q.byRef.get(row.source_key, row.source_ref);
+ return await store.tx(async () => {
+ const existing = await q.byRef.get(row.source_key, row.source_ref);
if (existing) {
if (existing) {
if (same(existing, row)) return { observation: existing, created: false };
if (same(existing, row)) return { observation: existing, created: false };
throw new ApiError(409, 'observation.conflict', 'This source reference already recorded a different value; a correction needs its own source_ref');
throw new ApiError(409, 'observation.conflict', 'This source reference already recorded a different value; a correction needs its own source_ref');
}
}
const id = newId('obs', store.now());
const id = newId('obs', store.now());
- q.insert.run({ id, ...row, recorded_by: recordedBy, recorded_at: store.now() });
- const obs = q.byId.get(id);
- ctx.outbox.emit({
+ await q.insert.run({ id, ...row, recorded_by: recordedBy, recorded_at: store.now() });
+ const obs = await q.byId.get(id);
+ await ctx.outbox.emit({
event_type: 'trade.observation.created', actor: actorOf(recordedBy), visibility: 'public', priority: 'low',
event_type: 'trade.observation.created', actor: actorOf(recordedBy), visibility: 'public', priority: 'low',
subject: { type: 'observation', id },
subject: { type: 'observation', id },
payload: {
payload: {
instrument: { id: instrument.id, symbol: instrument.symbol },
instrument: { id: instrument.id, symbol: instrument.symbol },
- ...api.dto(obs, { withFreshness: false }),
+ ...await api.dto(obs, { withFreshness: false }),
},
},
}, { traceparent });
}, { traceparent });
- ctx.freshness.noteRetrieval(obs.source_key, obs.retrieved_at);
- ctx.alerts.onObservation(obs, instrument, { traceparent });
- ctx.indexing.refresh(instrument, { traceparent });
+ await ctx.freshness.noteRetrieval(obs.source_key, obs.retrieved_at);
+ await ctx.alerts.onObservation(obs, instrument, { traceparent });
+ await ctx.indexing.refresh(instrument, { traceparent });
return { observation: obs, created: true };
return { observation: obs, created: true };
});
});
},
},
/** Is this observation stale at `now`? Its source's freshness, and its own max age. */
/** Is this observation stale at `now`? Its source's freshness, and its own max age. */
- staleness(obs, now = store.now()) {
- const src = ctx.freshness.view(obs.source_key, now);
+ async staleness(obs, now = store.now()) {
+ const src = await ctx.freshness.view(obs.source_key, now);
const own = obs.max_age_sec ? obs.observed_at + obs.max_age_sec * 1000 : null;
const own = obs.max_age_sec ? obs.observed_at + obs.max_age_sec * 1000 : null;
const ownStale = own != null && now > own;
const ownStale = own != null && now > own;
const candidates = [];
const candidates = [];
@@ -134,7 +134,7 @@ function createObservations({ store, ctx }) {
@@ -134,7 +134,7 @@ function createObservations({ store, ctx }) {
};
};
},
},
- dto(obs, { withFreshness = true, now = store.now() } = {}) {
+ async dto(obs, { withFreshness = true, now = store.now() } = {}) {
const out = {
const out = {
id: obs.id, metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency, period: obs.period,
id: obs.id, metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency, period: obs.period,
observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), recorded_at: iso(obs.recorded_at),
observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), recorded_at: iso(obs.recorded_at),
@@ -142,7 +142,7 @@ function createObservations({ store, ctx }) {
@@ -142,7 +142,7 @@ function createObservations({ store, ctx }) {
source: { key: obs.source_key, item_id: obs.source_item_id, url: obs.source_url, ref: obs.source_ref },
source: { key: obs.source_key, item_id: obs.source_item_id, url: obs.source_url, ref: obs.source_ref },
};
};
if (withFreshness) {
if (withFreshness) {
- const s = api.staleness(obs, now);
+ const s = await api.staleness(obs, now);
out.freshness = { stale: s.stale, stale_since: s.stale_since, reason: s.reason, source_status: s.source.status, source_last_success_at: s.source.last_success_at };
out.freshness = { stale: s.stale, stale_since: s.stale_since, reason: s.reason, source_status: s.source.status, source_last_success_at: s.source.last_success_at };
}
}
return out;
return out;
@@ -17,26 +17,26 @@ function createReading({ store, ctx }) {
@@ -17,26 +17,26 @@ function createReading({ store, ctx }) {
}
}
/** Everything the instrument page shows. docsPage: { limit, offset }. */
/** Everything the instrument page shows. docsPage: { limit, offset }. */
- function instrument(i, { limit = 25, offset = 0 } = {}) {
+ async function instrument(i, { limit = 25, offset = 0 } = {}) {
const now = store.now();
const now = store.now();
- const observations = ctx.observations.latest(i).map((o) => ctx.observations.dto(o, { now }));
- const docs = ctx.documents.forInstrument(i, { limit, offset }).map((d) => {
+ const observations = (await Promise.all((await ctx.observations.latest(i)).map(async (o) => await ctx.observations.dto(o, { now }))));
+ const docs = (await Promise.all((await ctx.documents.forInstrument(i, { limit, offset })).map(async (d) => {
const dto = ctx.documents.dto(d);
const dto = ctx.documents.dto(d);
- const f = ctx.freshness.view(d.source_key, now);
+ const f = await ctx.freshness.view(d.source_key, now);
dto.freshness = { stale: f.stale, stale_since: f.stale_since, reason: !f.known ? 'source_unknown' : f.stale ? 'source_stale' : null };
dto.freshness = { stale: f.stale, stale_since: f.stale_since, reason: !f.known ? 'source_unknown' : f.stale ? 'source_stale' : null };
return dto;
return dto;
- });
+ })));
const keys = [...new Set([...observations.map((o) => o.source.key), ...docs.map((d) => d.source.key)])];
const keys = [...new Set([...observations.map((o) => o.source.key), ...docs.map((d) => d.source.key)])];
- const sources = keys.sort().map((k) => ctx.freshness.view(k, now));
- const decision = ctx.indexing.decide(i, now);
+ const sources = (await Promise.all(keys.sort().map(async (k) => await ctx.freshness.view(k, now))));
+ const decision = await ctx.indexing.decide(i, now);
return {
return {
instrument: instrumentDto(i),
instrument: instrumentDto(i),
- aliases: ctx.instruments.aliases(i).map((a) => ({ kind: a.kind, value: a.value, normalized: a.normalized })),
+ aliases: (await ctx.instruments.aliases(i)).map((a) => ({ kind: a.kind, value: a.value, normalized: a.normalized })),
observations,
observations,
documents: docs,
documents: docs,
- documents_total: ctx.documents.count(i),
+ documents_total: await ctx.documents.count(i),
sources,
sources,
- context: ctx.context.published(i),
+ context: await ctx.context.published(i),
indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.reasons },
indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.reasons },
disclaimer: DISCLAIMER,
disclaimer: DISCLAIMER,
generated_at: iso(now),
generated_at: iso(now),
@@ -25,17 +25,18 @@ function createSync({ store, config, ctx, sources, log = console }) {
@@ -25,17 +25,18 @@ function createSync({ store, config, ctx, sources, log = console }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
get: db.prepare('SELECT * FROM trade_sync_state WHERE name = ?'),
get: db.prepare('SELECT * FROM trade_sync_state WHERE name = ?'),
- ensure: db.prepare("INSERT OR IGNORE INTO trade_sync_state (name, cursor, counts) VALUES (?, 0, '{}')"),
+ ensure: db.prepare("INSERT INTO trade_sync_state (name, cursor, counts) VALUES (?, 0, '{}') ON CONFLICT DO NOTHING"),
advance: db.prepare('UPDATE trade_sync_state SET cursor = @cursor, counts = @counts WHERE name = @name'),
advance: db.prepare('UPDATE trade_sync_state SET cursor = @cursor, counts = @counts WHERE name = @name'),
ok: db.prepare('UPDATE trade_sync_state SET last_ok_at = ?, last_error = NULL WHERE name = ?'),
ok: db.prepare('UPDATE trade_sync_state SET last_ok_at = ?, last_error = NULL WHERE name = ?'),
fail: db.prepare('UPDATE trade_sync_state SET last_error = ?, last_error_at = ? WHERE name = ?'),
fail: db.prepare('UPDATE trade_sync_state SET last_error = ?, last_error_at = ? WHERE name = ?'),
};
};
- q.ensure.run(CURSOR);
+ let ensured = false; // the cursor row, written on the first run (not at construction: the factory stays synchronous)
+ const ensureCursor = async () => { if (!ensured) { await q.ensure.run(CURSOR); ensured = true; } };
let running = null;
let running = null;
- function reportHealth(key, h, extra = {}) {
+ async function reportHealth(key, h, extra = {}) {
if (!h || typeof h !== 'object') return;
if (!h || typeof h !== 'object') return;
- ctx.freshness.report(key, {
+ await ctx.freshness.report(key, {
name: extra.name || null,
name: extra.name || null,
status: h.status || null,
status: h.status || null,
lastSuccessAt: parseTime(h.last_success_at),
lastSuccessAt: parseTime(h.last_success_at),
@@ -45,70 +46,71 @@ function createSync({ store, config, ctx, sources, log = console }) {
@@ -45,70 +46,71 @@ function createSync({ store, config, ctx, sources, log = console }) {
});
});
}
}
- function resolveKeys(keys) {
+ async function resolveKeys(keys) {
if (keys.cik) {
if (keys.cik) {
- const r = ctx.instruments.resolve(keys.cik, { kind: 'cik' });
+ const r = await ctx.instruments.resolve(keys.cik, { kind: 'cik' });
if (r.status === 'resolved') return r.instrument;
if (r.status === 'resolved') return r.instrument;
}
}
if (keys.symbol) {
if (keys.symbol) {
- const r = ctx.instruments.resolve(keys.symbol, { kind: 'ticker' });
+ const r = await ctx.instruments.resolve(keys.symbol, { kind: 'ticker' });
if (r.status === 'resolved') return r.instrument;
if (r.status === 'resolved') return r.instrument;
}
}
return null;
return null;
}
}
/** Apply one item. → a counter name. Inside the page transaction. */
/** Apply one item. → a counter name. Inside the page transaction. */
- function applyItem(item) {
- if (item.removed) return ctx.documents.remove(item.id, { at: parseTime(item.removed.at), reason: item.removed.reason }) ? 'documents_removed' : 'removed_unknown';
+ async function applyItem(item) {
+ if (item.removed) return await ctx.documents.remove(item.id, { at: parseTime(item.removed.at), reason: item.removed.reason }) ? 'documents_removed' : 'removed_unknown';
const m = mapItem(item);
const m = mapItem(item);
if (m.type === 'skip') return `skipped_${m.reason}`;
if (m.type === 'skip') return `skipped_${m.reason}`;
- const instrument = resolveKeys(m.keys);
+ const instrument = await resolveKeys(m.keys);
if (!instrument) return 'skipped_no_matching_instrument';
if (!instrument) return 'skipped_no_matching_instrument';
if (instrument.status !== 'active') return 'skipped_archived_instrument';
if (instrument.status !== 'active') return 'skipped_archived_instrument';
if (m.type === 'observation') {
if (m.type === 'observation') {
try {
try {
- const r = ctx.observations.record(m.observation, instrument, { recordedBy: 'svc:trade' });
+ const r = await ctx.observations.record(m.observation, instrument, { recordedBy: 'svc:trade' });
return r.created ? 'observations_created' : 'observations_unchanged';
return r.created ? 'observations_created' : 'observations_unchanged';
} catch (err) {
} catch (err) {
if (err && (err.status === 409 || err.status === 422)) return `skipped_${String(err.code || 'invalid').replace(/\W+/g, '_')}`;
if (err && (err.status === 409 || err.status === 422)) return `skipped_${String(err.code || 'invalid').replace(/\W+/g, '_')}`;
throw err;
throw err;
}
}
}
}
- const r = ctx.documents.upsert(m.document, instrument);
+ const r = await ctx.documents.upsert(m.document, instrument);
return r.created ? 'documents_created' : r.changed ? 'documents_updated' : 'documents_unchanged';
return r.created ? 'documents_created' : r.changed ? 'documents_updated' : 'documents_unchanged';
}
}
async function runOnce() {
async function runOnce() {
if (!sources.enabled) return { ok: false, skipped: 'disabled' };
if (!sources.enabled) return { ok: false, skipped: 'disabled' };
+ await ensureCursor();
const summary = { pages: 0, counts: {} };
const summary = { pages: 0, counts: {} };
try {
try {
const list = await sources.sources();
const list = await sources.sources();
for (const s of (list.sources || [])) {
for (const s of (list.sources || [])) {
- if (s && s.category === config.sources.category && s.key) reportHealth(s.key, s.health, s);
+ if (s && s.category === config.sources.category && s.key) await reportHealth(s.key, s.health, s);
}
}
for (let page = 0; page < config.sources.maxPagesPerRun; page++) {
for (let page = 0; page < config.sources.maxPagesPerRun; page++) {
- const state = q.get.get(CURSOR);
+ const state = await q.get.get(CURSOR);
const data = await sources.items({ after: state.cursor, limit: config.sources.pageLimit });
const data = await sources.items({ after: state.cursor, limit: config.sources.pageLimit });
const items = Array.isArray(data.items) ? data.items : [];
const items = Array.isArray(data.items) ? data.items : [];
- store.tx(() => {
- const counts = json(q.get.get(CURSOR).counts, {});
+ await store.tx(async () => {
+ const counts = json((await q.get.get(CURSOR)).counts, {});
for (const item of items) {
for (const item of items) {
- const k = applyItem(item);
+ const k = await applyItem(item);
counts[k] = (counts[k] || 0) + 1;
counts[k] = (counts[k] || 0) + 1;
summary.counts[k] = (summary.counts[k] || 0) + 1;
summary.counts[k] = (summary.counts[k] || 0) + 1;
}
}
- for (const [key, h] of Object.entries(data.sources || {})) reportHealth(key, h);
+ for (const [key, h] of Object.entries(data.sources || {})) await reportHealth(key, h);
const next = Number.isInteger(data.next_after) ? data.next_after : state.cursor;
const next = Number.isInteger(data.next_after) ? data.next_after : state.cursor;
- q.advance.run({ name: CURSOR, cursor: Math.max(next, state.cursor), counts: JSON.stringify(counts) });
+ await q.advance.run({ name: CURSOR, cursor: Math.max(next, state.cursor), counts: JSON.stringify(counts) });
});
});
summary.pages++;
summary.pages++;
if (!data.more || !items.length) break;
if (!data.more || !items.length) break;
}
}
- q.ok.run(store.now(), CURSOR);
+ await q.ok.run(store.now(), CURSOR);
ctx.outbox.kick();
ctx.outbox.kick();
return { ok: true, ...summary };
return { ok: true, ...summary };
} catch (err) {
} catch (err) {
- q.fail.run(String(err.message).slice(0, 500), store.now(), CURSOR);
+ await q.fail.run(String(err.message).slice(0, 500), store.now(), CURSOR);
log.warn('[Trade] Sources sync failed:', err.message);
log.warn('[Trade] Sources sync failed:', err.message);
return { ok: false, error: err.message, ...summary };
return { ok: false, error: err.message, ...summary };
}
}
@@ -123,8 +125,9 @@ function createSync({ store, config, ctx, sources, log = console }) {
@@ -123,8 +125,9 @@ function createSync({ store, config, ctx, sources, log = console }) {
if (!running) running = runOnce().finally(() => { running = null; });
if (!running) running = runOnce().finally(() => { running = null; });
return running;
return running;
},
},
- state() {
- const s = q.get.get(CURSOR);
+ async state() {
+ await ensureCursor();
+ const s = await q.get.get(CURSOR);
return { enabled: sources.enabled, cursor: s.cursor, last_ok_at: iso(s.last_ok_at), last_error: s.last_error, last_error_at: iso(s.last_error_at), counts: json(s.counts, {}) };
return { enabled: sources.enabled, cursor: s.cursor, last_ok_at: iso(s.last_ok_at), last_error: s.last_error, last_error_at: iso(s.last_error_at), counts: json(s.counts, {}) };
},
},
};
};
@@ -21,64 +21,65 @@ function createWatchlists({ store, config, ctx }) {
@@ -21,64 +21,65 @@ function createWatchlists({ store, config, ctx }) {
items: db.prepare(`SELECT w.*, i.symbol, i.name, i.kind, i.exchange, i.status FROM trade_watchlist_items w
items: db.prepare(`SELECT w.*, i.symbol, i.name, i.kind, i.exchange, i.status FROM trade_watchlist_items w
JOIN trade_instruments i ON i.id = w.instrument_id WHERE w.watchlist_id = ? ORDER BY i.symbol`),
JOIN trade_instruments i ON i.id = w.instrument_id WHERE w.watchlist_id = ? ORDER BY i.symbol`),
itemCount: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?'),
itemCount: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?'),
- addItem: db.prepare('INSERT OR IGNORE INTO trade_watchlist_items (watchlist_id, instrument_id, note, added_at) VALUES (?, ?, ?, ?)'),
+ addItem: db.prepare('INSERT INTO trade_watchlist_items (watchlist_id, instrument_id, note, added_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING'),
removeItem: db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ? AND instrument_id = ?'),
removeItem: db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ? AND instrument_id = ?'),
touch: db.prepare('UPDATE trade_watchlists SET updated_at = ? WHERE id = ?'),
touch: db.prepare('UPDATE trade_watchlists SET updated_at = ? WHERE id = ?'),
};
};
- const unique = (fn) => {
- try { return fn(); } catch (err) {
- if (err && /UNIQUE constraint failed: trade_watchlists/.test(err.message)) throw new ApiError(409, 'watchlist.name_taken', 'You already have a watchlist with this name');
+ const unique = async (fn) => {
+ try { return await fn(); } catch (err) {
+ // PostgreSQL unique_violation (23505) on trade_watchlists: the person's names are unique.
+ if (err && err.code === '23505' && err.table === 'trade_watchlists') throw new ApiError(409, 'watchlist.name_taken', 'You already have a watchlist with this name');
throw err;
throw err;
}
}
};
};
const api = {
const api = {
/** The owner's watchlist, or 404 for anyone else (existence is not disclosed). */
/** The owner's watchlist, or 404 for anyone else (existence is not disclosed). */
- mustOwn(subject, id) {
- const w = q.byId.get(String(id || ''));
+ async mustOwn(subject, id) {
+ const w = await q.byId.get(String(id || ''));
if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');
if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');
return w;
return w;
},
},
- forOwner: (subject) => (subject ? q.forOwner.all(subject) : []),
- items: (w) => q.items.all(w.id),
+ forOwner: async (subject) => (subject ? await q.forOwner.all(subject) : []),
+ items: async (w) => await q.items.all(w.id),
- create(subject, input) {
+ async create(subject, input) {
if (!subject) throw new ApiError(403, 'subject.required', 'Watchlists belong to a person');
if (!subject) throw new ApiError(403, 'subject.required', 'Watchlists belong to a person');
const name = str(input.name, 80, 'name', { required: true });
const name = str(input.name, 80, 'name', { required: true });
- if (q.count.get(subject).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);
+ if ((await q.count.get(subject)).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);
const id = newId('wl', store.now());
const id = newId('wl', store.now());
- unique(() => q.insert.run(id, subject, name, store.now(), store.now()));
- return q.byId.get(id);
+ await unique(async () => await q.insert.run(id, subject, name, store.now(), store.now()));
+ return await q.byId.get(id);
},
},
- rename(w, input) {
+ async rename(w, input) {
const name = str(input.name, 80, 'name', { required: true });
const name = str(input.name, 80, 'name', { required: true });
- unique(() => q.rename.run(name, store.now(), w.id));
- return q.byId.get(w.id);
+ await unique(async () => await q.rename.run(name, store.now(), w.id));
+ return await q.byId.get(w.id);
},
},
- remove(w) { return store.tx(() => { db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return q.remove.run(w.id).changes > 0; }); },
+ async remove(w) { return await store.tx(async () => { await db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return (await q.remove.run(w.id)).changes > 0; }); },
- add(w, instrument, note = null) {
+ async add(w, instrument, note = null) {
if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
- if (q.itemCount.get(w.id).n >= config.limits.itemsPerWatchlist) throw new ApiError(429, 'watchlist.full', `At most ${config.limits.itemsPerWatchlist} instruments per watchlist`);
- const added = q.addItem.run(w.id, instrument.id, str(note, 300, 'note'), store.now()).changes > 0;
- if (added) q.touch.run(store.now(), w.id);
+ if ((await q.itemCount.get(w.id)).n >= config.limits.itemsPerWatchlist) throw new ApiError(429, 'watchlist.full', `At most ${config.limits.itemsPerWatchlist} instruments per watchlist`);
+ const added = (await q.addItem.run(w.id, instrument.id, str(note, 300, 'note'), store.now())).changes > 0;
+ if (added) await q.touch.run(store.now(), w.id);
return added;
return added;
},
},
- drop(w, instrument) {
+ async drop(w, instrument) {
if (!instrument) return false;
if (!instrument) return false;
- const removed = q.removeItem.run(w.id, instrument.id).changes > 0;
- if (removed) q.touch.run(store.now(), w.id);
+ const removed = (await q.removeItem.run(w.id, instrument.id)).changes > 0;
+ if (removed) await q.touch.run(store.now(), w.id);
return removed;
return removed;
},
},
- dto(w, { withItems = true } = {}) {
+ async dto(w, { withItems = true } = {}) {
const out = { id: w.id, name: w.name, created_at: iso(w.created_at), updated_at: iso(w.updated_at) };
const out = { id: w.id, name: w.name, created_at: iso(w.created_at), updated_at: iso(w.updated_at) };
if (withItems) {
if (withItems) {
- out.items = q.items.all(w.id).map((it) => ({
+ out.items = (await q.items.all(w.id)).map((it) => ({
instrument: { id: it.instrument_id, symbol: it.symbol, name: it.name, kind: it.kind, exchange: it.exchange, status: it.status, url: ctx.urls.instrument({ symbol: it.symbol }) },
instrument: { id: it.instrument_id, symbol: it.symbol, name: it.name, kind: it.kind, exchange: it.exchange, status: it.status, url: ctx.urls.instrument({ symbol: it.symbol }) },
note: it.note, added_at: iso(it.added_at),
note: it.note, added_at: iso(it.added_at),
}));
}));
@@ -17,7 +17,7 @@
@@ -17,7 +17,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 createTradeOutbox({ db, config, fetchImpl, now, log = console }) {
function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
@@ -33,7 +33,9 @@ function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -33,7 +33,9 @@ function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {
}
}
const events = createEventsClient(createClient(clientOpts), { source: 'trade' });
const events = createEventsClient(createClient(clientOpts), { source: 'trade' });
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,
@@ -43,11 +45,10 @@ function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -43,11 +45,10 @@ function createTradeOutbox({ 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 });
}
}
return {
return {
@@ -57,7 +58,7 @@ function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -57,7 +58,7 @@ function createTradeOutbox({ db, config, fetchImpl, now, log = console }) {
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
stop: () => outbox.stop(),
stop: () => outbox.stop(),
kick() { if (enabled) outbox.kick(); },
kick() { if (enabled) outbox.kick(); },
- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
};
};
}
}
@@ -11,15 +11,14 @@
@@ -11,15 +11,14 @@
* reads the items themselves from the Sources API.
* reads the items themselves from the Sources API.
*/
*/
const express = require('express');
const express = require('express');
-const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
function createWebhook({ store, config, sync, log = console }) {
function createWebhook({ store, config, sync, log = console }) {
const router = express.Router();
const router = express.Router();
- const inbox = createInbox(store.db, { now: store.now });
- inbox.ensureSchema();
+ const inbox = createPgInbox(store.db, { now: store.now }); // idempotency_receipts: migrations/0001_initial.sql
- router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), function receiveEventsDelivery(req, res) {
+ router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), async function receiveEventsDelivery(req, res, next) {
if (!config.events.webhookSecret) return http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
if (!config.events.webhookSecret) return http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
// Signature v2 only: HMAC over "<t>.<raw body>" with t within ±300 s; a v1-only (v2 stripped) or stale delivery is refused.
// Signature v2 only: HMAC over "<t>.<raw body>" with t within ±300 s; a v1-only (v2 stripped) or stale delivery is refused.
@@ -27,7 +26,8 @@ function createWebhook({ store, config, sync, log = console }) {
@@ -27,7 +26,8 @@ function createWebhook({ store, config, sync, log = console }) {
if (!delivery) return http.sendProblem(res, 401, 'webhook.signature_invalid', { detail: 'The delivery signature does not verify', ctx: req.ov });
if (!delivery) return http.sendProblem(res, 401, 'webhook.signature_invalid', { detail: 'The delivery signature does not verify', ctx: req.ov });
const e = delivery.event;
const e = delivery.event;
if (typeof e.event_id !== 'string' || typeof e.event_type !== 'string') return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'Not an event envelope', ctx: req.ov });
if (typeof e.event_id !== 'string' || typeof e.event_type !== 'string') return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'Not an event envelope', ctx: req.ov });
- const r = inbox.once('sources', e.event_id, () => true);
+ let r;
+ try { r = await inbox.once('sources', e.event_id, async () => true); } catch (err) { return next(err); }
const wake = !r.duplicate && /^sources\.(item\.(created|updated|removed)|fetch\.failed)$/.test(e.event_type);
const wake = !r.duplicate && /^sources\.(item\.(created|updated|removed)|fetch\.failed)$/.test(e.event_type);
if (wake) sync.run().catch((err) => log.warn('[Trade] sync after webhook failed:', err.message));
if (wake) sync.run().catch((err) => log.warn('[Trade] sync after webhook failed:', err.message));
res.status(200).json({ accepted: true, duplicate: r.duplicate, sync: wake });
res.status(200).json({ accepted: true, duplicate: r.duplicate, sync: wake });
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* Never limited: /api/health, /api/ready, /release.json, /release-metrics, /metrics, sign-in, the pages
* Never limited: /api/health, /api/ready, /release.json, /release-metrics, /metrics, sign-in, the pages
* and feeds people read, and the signed Events deliveries at /internal/events.
* and feeds people read, and the signed Events deliveries at /internal/events.
*/
*/
-const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
const FIRST_PARTY = /^svc:/;
@@ -75,7 +75,7 @@ const BUDGETS = {
@@ -75,7 +75,7 @@ const BUDGETS = {
* limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on a counted
* limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on a counted
* GET/HEAD) and limits.budget(name) (one of BUDGETS).
* GET/HEAD) and limits.budget(name) (one of BUDGETS).
*/
*/
-function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {
const refused = registry
const refused = registry
? registry.counter({ name: 'trade_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'trade_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -83,6 +83,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -83,6 +83,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
actor,
actor,
now,
now,
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is a subject id, a principal or an address, never a token.
// The actor is a subject id, a principal or an address, never a token.
log.warn(`[Trade] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
log.warn(`[Trade] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -76,8 +76,8 @@ function createApi(ctx) {
@@ -76,8 +76,8 @@ function createApi(ctx) {
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
- function mustInstrument(req) {
- const i = instruments.bySymbol(req.params.symbol);
+ async function mustInstrument(req) {
+ const i = await instruments.bySymbol(req.params.symbol);
if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
return i;
return i;
}
}
@@ -103,14 +103,14 @@ function createApi(ctx) {
@@ -103,14 +103,14 @@ function createApi(ctx) {
// ── Instruments ─────────────────────────────────────────
// ── Instruments ─────────────────────────────────────────
- define(router, 'get', '/instruments', 'getInstruments', run((req) => {
+ define(router, 'get', '/instruments', 'getInstruments', run(async (req) => {
const { limit, offset } = paging(req);
const { limit, offset } = paging(req);
- const page = instruments.page({ limit, offset });
+ const page = await instruments.page({ limit, offset });
return { total: page.total, instruments: page.instruments.map(reading.instrumentDto), disclaimer: DISCLAIMER };
return { total: page.total, instruments: page.instruments.map(reading.instrumentDto), disclaimer: DISCLAIMER };
}));
}));
- define(router, 'get', '/instruments/resolve', 'resolveInstrument', guard(C.INSTRUMENT_RESOLVE), run((req) => {
- const r = instruments.resolve(req.query.q, { kind: req.query.kind || null });
+ define(router, 'get', '/instruments/resolve', 'resolveInstrument', guard(C.INSTRUMENT_RESOLVE), run(async (req) => {
+ const r = await instruments.resolve(req.query.q, { kind: req.query.kind || null });
return {
return {
query: r.query, status: r.status, match: r.match,
query: r.query, status: r.status, match: r.match,
instrument: r.instrument ? reading.instrumentDto(r.instrument) : null,
instrument: r.instrument ? reading.instrumentDto(r.instrument) : null,
@@ -118,130 +118,130 @@ function createApi(ctx) {
@@ -118,130 +118,130 @@ function createApi(ctx) {
};
};
}));
}));
- define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {
+ define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run(async (req) => {
mustEditor(req);
mustEditor(req);
const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;
const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;
- const i = instruments.create(req.body || {}, by);
- store.tx(() => indexing.refresh(i, tp(req)));
- return { instrument: reading.instrumentDto(i), aliases: instruments.aliases(i) };
+ const i = await instruments.create(req.body || {}, by);
+ await store.tx(async () => await indexing.refresh(i, tp(req)));
+ return { instrument: reading.instrumentDto(i), aliases: await instruments.aliases(i) };
}, 201));
}, 201));
- define(router, 'get', '/instruments/:symbol', 'getInstrument', run((req) => {
- const { decision, ...data } = reading.instrument(mustInstrument(req), paging(req, 25, 100));
+ define(router, 'get', '/instruments/:symbol', 'getInstrument', run(async (req) => {
+ const { decision, ...data } = await reading.instrument(await mustInstrument(req), paging(req, 25, 100));
return data;
return data;
}));
}));
- define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {
+ define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run(async (req) => {
mustEditor(req);
mustEditor(req);
- const i = instruments.update(mustInstrument(req), req.body || {});
- store.tx(() => indexing.refresh(i, tp(req)));
+ const i = await instruments.update(await mustInstrument(req), req.body || {});
+ await store.tx(async () => await indexing.refresh(i, tp(req)));
return { instrument: reading.instrumentDto(i) };
return { instrument: reading.instrumentDto(i) };
}));
}));
- define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {
+ define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run(async (req) => {
mustEditor(req);
mustEditor(req);
- const i = mustInstrument(req);
+ const i = await mustInstrument(req);
const b = req.body || {};
const b = req.body || {};
- const alias = instruments.addAlias(i, String(b.kind || ''), b.value, req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject);
- return { alias, aliases: instruments.aliases(i) };
+ const alias = await instruments.addAlias(i, String(b.kind || ''), b.value, req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject);
+ return { alias, aliases: await instruments.aliases(i) };
}, 201));
}, 201));
- define(router, 'get', '/instruments/:symbol/observations', 'getObservations', run((req) => {
- const i = mustInstrument(req);
- const list = observations.history(i, { metric: req.query.metric || null, before: req.query.before || null, limit: req.query.limit });
- return { instrument: reading.instrumentDto(i), observations: list.map((o) => observations.dto(o)), disclaimer: DISCLAIMER };
+ define(router, 'get', '/instruments/:symbol/observations', 'getObservations', run(async (req) => {
+ const i = await mustInstrument(req);
+ const list = await observations.history(i, { metric: req.query.metric || null, before: req.query.before || null, limit: req.query.limit });
+ return { instrument: reading.instrumentDto(i), observations: (await Promise.all(list.map(async (o) => await observations.dto(o)))), disclaimer: DISCLAIMER };
}));
}));
- define(router, 'get', '/instruments/:symbol/documents', 'getDocuments', run((req) => {
- const i = mustInstrument(req);
- return { instrument: reading.instrumentDto(i), total: documents.count(i), documents: documents.forInstrument(i, paging(req)).map(documents.dto) };
+ define(router, 'get', '/instruments/:symbol/documents', 'getDocuments', run(async (req) => {
+ const i = await mustInstrument(req);
+ return { instrument: reading.instrumentDto(i), total: await documents.count(i), documents: (await documents.forInstrument(i, paging(req))).map(documents.dto) };
}));
}));
// ── Observations (first-party feeds) ────────────────────
// ── Observations (first-party feeds) ────────────────────
- define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), B('trade.observation.write'), jsonBody, run((req) => {
+ define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), B('trade.observation.write'), jsonBody, run(async (req) => {
if (req.viewer.kind !== 'service') throw new ApiError(403, 'observation.services_only', 'Observations come from sources, recorded by a service with trade.observation.write');
if (req.viewer.kind !== 'service') throw new ApiError(403, 'observation.services_only', 'Observations come from sources, recorded by a service with trade.observation.write');
const b = req.body || {};
const b = req.body || {};
- const instrument = b.instrument_id ? instruments.get(String(b.instrument_id)) : instruments.bySymbol(b.symbol);
+ const instrument = b.instrument_id ? await instruments.get(String(b.instrument_id)) : await instruments.bySymbol(b.symbol);
if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument (symbol or instrument_id)');
if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument (symbol or instrument_id)');
- const out = observations.record(b, instrument, { recordedBy: req.viewer.service, ...tp(req) });
+ const out = await observations.record(b, instrument, { recordedBy: req.viewer.service, ...tp(req) });
ctx.outbox.kick();
ctx.outbox.kick();
- return { observation: observations.dto(out.observation), created: out.created };
+ return { observation: await observations.dto(out.observation), created: out.created };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
// ── Context ─────────────────────────────────────────────
// ── Context ─────────────────────────────────────────────
- define(router, 'get', '/instruments/:symbol/context', 'getContext', guard(C.CONTEXT_READ), run((req) => {
- const i = mustInstrument(req);
- const out = { instrument: reading.instrumentDto(i), published: context.published(i), disclaimer: DISCLAIMER };
+ define(router, 'get', '/instruments/:symbol/context', 'getContext', guard(C.CONTEXT_READ), run(async (req) => {
+ const i = await mustInstrument(req);
+ const out = { instrument: reading.instrumentDto(i), published: await context.published(i), disclaimer: DISCLAIMER };
if (req.query.all === '1') {
if (req.query.all === '1') {
const v = req.viewer;
const v = req.viewer;
if (!(v.kind === 'service' || (v.kind === 'user' && v.editor))) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors see drafts');
if (!(v.kind === 'service' || (v.kind === 'user' && v.editor))) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors see drafts');
- out.pending = context.pending(i);
+ out.pending = await context.pending(i);
}
}
return out;
return out;
}));
}));
- define(router, 'get', '/instruments/:symbol/context/input', 'getContextInput', guard(C.CONTEXT_READ), run((req) => context.input(mustInstrument(req))));
+ define(router, 'get', '/instruments/:symbol/context/input', 'getContextInput', guard(C.CONTEXT_READ), run(async (req) => await context.input(await mustInstrument(req))));
- define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), B('trade.context.propose'), jsonBody, run((req) => {
- const i = mustInstrument(req);
- const out = context.propose(req.viewer, i, req.body || {});
+ define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), B('trade.context.propose'), jsonBody, run(async (req) => {
+ const i = await mustInstrument(req);
+ const out = await context.propose(req.viewer, i, req.body || {});
ctx.outbox.kick();
ctx.outbox.kick();
- return { revision: context.view(ctx.instruments.get(i.id), out.revision), published: out.published };
+ return { revision: await context.view(await ctx.instruments.get(i.id), out.revision), published: out.published };
}, 201));
}, 201));
- define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', B('trade.context.publish'), jsonBody, run((req) => {
- const i = mustInstrument(req);
+ define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', B('trade.context.publish'), jsonBody, run(async (req) => {
+ const i = await mustInstrument(req);
const b = req.body || {};
const b = req.body || {};
- const out = context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });
+ const out = await context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });
ctx.outbox.kick();
ctx.outbox.kick();
return out;
return out;
}, 201));
}, 201));
// ── Sources ─────────────────────────────────────────────
// ── Sources ─────────────────────────────────────────────
- define(router, 'get', '/sources', 'getSourceFreshness', run(() => ({ sources: freshness.all(), sync: ctx.sync.state() })));
+ define(router, 'get', '/sources', 'getSourceFreshness', run(async () => ({ sources: await freshness.all(), sync: await ctx.sync.state() })));
// ── Watchlists (private) ────────────────────────────────
// ── Watchlists (private) ────────────────────────────────
- define(router, 'get', '/watchlists', 'getWatchlists', guard(C.WATCHLIST_READ), run((req) => ({ watchlists: watchlists.forOwner(owner(req)).map((w) => watchlists.dto(w)) })));
+ define(router, 'get', '/watchlists', 'getWatchlists', guard(C.WATCHLIST_READ), run(async (req) => ({ watchlists: (await Promise.all((await watchlists.forOwner(owner(req))).map(async (w) => await watchlists.dto(w)))) })));
- define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run((req) => ({ watchlist: watchlists.dto(watchlists.create(owner(req), req.body || {})) }), 201));
+ define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.create(owner(req), req.body || {})) }), 201));
- define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run((req) => ({ watchlist: watchlists.dto(watchlists.mustOwn(owner(req), req.params.id)) })));
+ define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run(async (req) => ({ watchlist: await watchlists.dto(await watchlists.mustOwn(owner(req), req.params.id)) })));
- define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run((req) => {
- const w = watchlists.mustOwn(owner(req), req.params.id);
- return { watchlist: watchlists.dto(watchlists.rename(w, req.body || {})) };
+ define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run(async (req) => {
+ const w = await watchlists.mustOwn(owner(req), req.params.id);
+ return { watchlist: await watchlists.dto(await watchlists.rename(w, req.body || {})) };
}));
}));
- define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run((req) => {
- const w = watchlists.mustOwn(owner(req), req.params.id);
- const added = watchlists.add(w, instruments.bySymbol(req.params.symbol), (req.body || {}).note);
- return { added, watchlist: watchlists.dto(w) };
+ define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run(async (req) => {
+ const w = await watchlists.mustOwn(owner(req), req.params.id);
+ const added = await watchlists.add(w, await instruments.bySymbol(req.params.symbol), (req.body || {}).note);
+ return { added, watchlist: await watchlists.dto(w) };
}));
}));
- define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), run((req) => {
- const w = watchlists.mustOwn(owner(req), req.params.id);
- return { removed: watchlists.drop(w, instruments.bySymbol(req.params.symbol)), watchlist: watchlists.dto(w) };
+ define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), run(async (req) => {
+ const w = await watchlists.mustOwn(owner(req), req.params.id);
+ return { removed: await watchlists.drop(w, await instruments.bySymbol(req.params.symbol)), watchlist: await watchlists.dto(w) };
}));
}));
- define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), B('trade.watchlist.update'), run((req) => ({ deleted: watchlists.remove(watchlists.mustOwn(owner(req), req.params.id)) })));
+ define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), B('trade.watchlist.update'), run(async (req) => ({ deleted: await watchlists.remove(await watchlists.mustOwn(owner(req), req.params.id)) })));
// ── Alerts (private) ────────────────────────────────────
// ── Alerts (private) ────────────────────────────────────
- define(router, 'get', '/alerts', 'getAlertRules', guard(C.ALERT_READ), run((req) => ({ rules: alerts.forOwner(owner(req)) })));
+ define(router, 'get', '/alerts', 'getAlertRules', guard(C.ALERT_READ), run(async (req) => ({ rules: await alerts.forOwner(owner(req)) })));
- define(router, 'get', '/alerts/deliveries', 'getAlertDeliveries', guard(C.ALERT_READ), run((req) => ({ deliveries: alerts.deliveries(owner(req), req.query.limit) })));
+ define(router, 'get', '/alerts/deliveries', 'getAlertDeliveries', guard(C.ALERT_READ), run(async (req) => ({ deliveries: await alerts.deliveries(owner(req), req.query.limit) })));
- define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), B('trade.alert.create'), jsonBody, run((req) => {
+ define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), B('trade.alert.create'), jsonBody, run(async (req) => {
const b = req.body || {};
const b = req.body || {};
- const rule = alerts.create(owner(req), instruments.bySymbol(b.symbol), b);
- return { rule: alerts.dto(rule) };
+ const rule = await alerts.create(owner(req), await instruments.bySymbol(b.symbol), b);
+ return { rule: await alerts.dto(rule) };
}, 201));
}, 201));
- define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), B('trade.alert.delete'), run((req) => ({ deleted: alerts.remove(owner(req), req.params.id) })));
+ define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), B('trade.alert.delete'), run(async (req) => ({ deleted: await alerts.remove(owner(req), req.params.id) })));
router.use(function apiNotFound(req, res) { contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }); });
router.use(function apiNotFound(req, res) { contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }); });
return router;
return router;
@@ -84,18 +84,20 @@ function createDiscovery(ctx) {
@@ -84,18 +84,20 @@ function createDiscovery(ctx) {
}));
}));
});
});
- define(router, 'get', '/sitemap.xml', 'sitemapIndex', (_req, res) => {
- const times = instruments.active().filter((i) => indexing.decide(i).indexable && i.context_published_at).map((i) => i.context_published_at);
+ define(router, 'get', '/sitemap.xml', 'sitemapIndex', async (_req, res) => {
+ const active = await instruments.active();
+ const decisions = await Promise.all(active.map(async (i) => await indexing.decide(i)));
+ const times = active.filter((i, n) => decisions[n].indexable && i.context_published_at).map((i) => i.context_published_at);
const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;
const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;
xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));
xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));
});
});
- define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', (_req, res) => {
- const entries = instruments.active().map((i) => ({
+ define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', async (_req, res) => {
+ const entries = (await Promise.all((await instruments.active()).map(async (i) => ({
loc: urls.instrument(i),
loc: urls.instrument(i),
...(i.context_published_at ? { lastmod: new Date(i.context_published_at).toISOString() } : {}),
...(i.context_published_at ? { lastmod: new Date(i.context_published_at).toISOString() } : {}),
- decision: indexing.decide(i),
- }));
+ decision: await indexing.decide(i),
+ }))));
xml(res, seo.sitemap(entries).files[0]);
xml(res, seo.sitemap(entries).files[0]);
});
});
@@ -106,17 +108,17 @@ function createDiscovery(ctx) {
@@ -106,17 +108,17 @@ function createDiscovery(ctx) {
description: `Filings and documents recorded from OpenVibe.Sources, with their source dates. ${DISCLAIMER}`,
description: `Filings and documents recorded from OpenVibe.Sources, with their source dates. ${DISCLAIMER}`,
language: 'en',
language: 'en',
});
});
- const recentItems = () => documents.recent(50).map((d) => feedItem(d, instruments.get(d.instrument_id)));
- define(router, 'get', '/feed.xml', 'documentsRss', (_req, res) => sendFeed(res, 'rss', siteChannel('rss'), recentItems()));
- define(router, 'get', '/atom.xml', 'documentsAtom', (_req, res) => sendFeed(res, 'atom', siteChannel('atom'), recentItems()));
- define(router, 'get', '/feed.json', 'documentsJsonFeed', (_req, res) => sendFeed(res, 'json', siteChannel('json'), recentItems()));
+ const recentItems = async () => (await Promise.all((await documents.recent(50)).map(async (d) => feedItem(d, await instruments.get(d.instrument_id)))));
+ define(router, 'get', '/feed.xml', 'documentsRss', async (_req, res) => sendFeed(res, 'rss', siteChannel('rss'), await recentItems()));
+ define(router, 'get', '/atom.xml', 'documentsAtom', async (_req, res) => sendFeed(res, 'atom', siteChannel('atom'), await recentItems()));
+ define(router, 'get', '/feed.json', 'documentsJsonFeed', async (_req, res) => sendFeed(res, 'json', siteChannel('json'), await recentItems()));
- define(router, 'get', '/i/:symbol/documents.:format', 'instrumentDocumentsFeed', (req, res) => {
+ define(router, 'get', '/i/:symbol/documents.:format', 'instrumentDocumentsFeed', async (req, res) => {
const type = { xml: 'rss', atom: 'atom', json: 'json' }[req.params.format];
const type = { xml: 'rss', atom: 'atom', json: 'json' }[req.params.format];
- const instrument = instruments.bySymbol(req.params.symbol);
+ const instrument = await instruments.bySymbol(req.params.symbol);
if (!type || !instrument || instrument.status !== 'active') return common.notFound(req, res);
if (!type || !instrument || instrument.status !== 'active') return common.notFound(req, res);
if (instrument.symbol !== req.params.symbol) return res.redirect(301, urls.path.documentsFeed(instrument, type));
if (instrument.symbol !== req.params.symbol) return res.redirect(301, urls.path.documentsFeed(instrument, type));
- const items = documents.forInstrument(instrument, { limit: 50 }).map((d) => feedItem(d, instrument));
+ const items = (await documents.forInstrument(instrument, { limit: 50 })).map((d) => feedItem(d, instrument));
sendFeed(res, type, {
sendFeed(res, type, {
title: `${instrument.symbol} documents — OpenVibe.Trade`, link: urls.instrument(instrument),
title: `${instrument.symbol} documents — OpenVibe.Trade`, link: urls.instrument(instrument),
feedUrl: abs(urls.path.documentsFeed(instrument, type)),
feedUrl: abs(urls.path.documentsFeed(instrument, type)),
@@ -36,8 +36,8 @@ function createEditor(ctx) {
@@ -36,8 +36,8 @@ function createEditor(ctx) {
// does the same thing, checked once the session, form token and editor list are.
// does the same thing, checked once the session, form token and editor list are.
const B = (name) => ctx.limits.budget(name);
const B = (name) => ctx.limits.budget(name);
- function mustInstrument(req) {
- const i = instruments.bySymbol(req.params.symbol);
+ async function mustInstrument(req) {
+ const i = await instruments.bySymbol(req.params.symbol);
if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
return i;
return i;
}
}
@@ -49,58 +49,58 @@ function createEditor(ctx) {
@@ -49,58 +49,58 @@ function createEditor(ctx) {
common.after(res, path, { e: e.message });
common.after(res, path, { e: e.message });
}
}
- define(router, 'get', '/editor', 'showEditor', requireEditor, (req, res) => {
- const all = instruments.active();
+ define(router, 'get', '/editor', 'showEditor', requireEditor, async (req, res) => {
+ const all = await instruments.active();
const pending = [];
const pending = [];
- for (const i of all) for (const p of context.pending(i)) pending.push({ symbol: i.symbol, revision: p.revision, label: p.needs_review ? 'AI draft, needs review' : 'not published' });
+ for (const i of all) for (const p of await context.pending(i)) pending.push({ symbol: i.symbol, revision: p.revision, label: p.needs_review ? 'AI draft, needs review' : 'not published' });
common.page(req, res, { title: 'Editor', decision, personal: true, body: views.editorHome({ instruments: all, pending, csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS }) });
common.page(req, res, { title: 'Editor', decision, personal: true, body: views.editorHome({ instruments: all, pending, csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS }) });
});
});
- define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, B('trade.instrument.manage'), (req, res) => {
+ define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, B('trade.instrument.manage'), async (req, res) => {
try {
try {
- const i = instruments.create(req.body, req.viewer.subject);
- ctx.store.tx(() => indexing.refresh(i));
+ const i = await instruments.create(req.body, req.viewer.subject);
+ await ctx.store.tx(async () => await indexing.refresh(i));
common.after(res, editPath(i), { n: 'instrument_created' });
common.after(res, editPath(i), { n: 'instrument_created' });
} catch (err) { fail(req, res, '/editor', err); }
} catch (err) { fail(req, res, '/editor', err); }
});
});
- define(router, 'get', '/editor/i/:symbol', 'showEditorInstrument', requireEditor, (req, res) => {
+ define(router, 'get', '/editor/i/:symbol', 'showEditorInstrument', requireEditor, async (req, res) => {
let i;
let i;
- try { i = mustInstrument(req); } catch (err) { return common.failure(req, res, err); }
- const revisions = context.revisions(i, { limit: 30 }).map((r) => context.view(i, r));
+ try { i = await mustInstrument(req); } catch (err) { return common.failure(req, res, err); }
+ const revisions = (await Promise.all((await context.revisions(i, { limit: 30 })).map(async (r) => await context.view(i, r))));
common.page(req, res, {
common.page(req, res, {
title: `Editor · ${i.symbol}`, decision, personal: true,
title: `Editor · ${i.symbol}`, decision, personal: true,
body: views.editorInstrument({
body: views.editorInstrument({
- instrument: i, aliases: instruments.aliases(i), revisions, head: context.head(i) ? context.head(i).number : 0,
- documents: documents.forInstrument(i, { limit: 30 }), observations: observations.latest(i),
+ instrument: i, aliases: await instruments.aliases(i), revisions, head: await context.head(i) ? (await context.head(i)).number : 0,
+ documents: await documents.forInstrument(i, { limit: 30 }), observations: await observations.latest(i),
csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS,
csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS,
}),
}),
});
});
});
});
- define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, B('trade.instrument.manage'), (req, res) => {
+ define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, B('trade.instrument.manage'), async (req, res) => {
try {
try {
- const i = mustInstrument(req);
- const updated = instruments.update(i, { name: req.body.name, kind: req.body.kind, exchange: req.body.exchange, cik: req.body.cik, currency: req.body.currency, status: req.body.status });
- ctx.store.tx(() => indexing.refresh(updated));
+ const i = await mustInstrument(req);
+ const updated = await instruments.update(i, { name: req.body.name, kind: req.body.kind, exchange: req.body.exchange, cik: req.body.cik, currency: req.body.currency, status: req.body.status });
+ await ctx.store.tx(async () => await indexing.refresh(updated));
common.after(res, editPath(updated), { n: 'instrument_saved' });
common.after(res, editPath(updated), { n: 'instrument_saved' });
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
});
- define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, B('trade.instrument.manage'), (req, res) => {
+ define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, B('trade.instrument.manage'), async (req, res) => {
try {
try {
- const i = mustInstrument(req);
- instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);
+ const i = await mustInstrument(req);
+ await instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);
common.after(res, editPath(i), { n: 'alias_added' });
common.after(res, editPath(i), { n: 'alias_added' });
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
});
- define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, B('trade.context.propose'), (req, res) => {
+ define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, B('trade.context.propose'), async (req, res) => {
try {
try {
- const i = mustInstrument(req);
+ const i = await mustInstrument(req);
const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];
const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];
const cites = raw.map((s) => { const [kind, id] = String(s).split(':'); return { kind, id }; });
const cites = raw.map((s) => { const [kind, id] = String(s).split(':'); return { kind, id }; });
- const out = context.propose(req.viewer, i, { body: req.body.body, cites, expected_revision: req.body.expected_revision, publish: req.body.publish === '1' });
+ const out = await context.propose(req.viewer, i, { body: req.body.body, cites, expected_revision: req.body.expected_revision, publish: req.body.publish === '1' });
common.after(res, editPath(i), { n: out.published ? 'context_published' : 'context_saved' });
common.after(res, editPath(i), { n: out.published ? 'context_published' : 'context_saved' });
} catch (err) {
} catch (err) {
if (err && err.code === 'revision.conflict') return common.after(res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, { e: 'Someone saved a newer revision while you were writing. Your text was not saved: copy it, reload and try again.' });
if (err && err.code === 'revision.conflict') return common.after(res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, { e: 'Someone saved a newer revision while you were writing. Your text was not saved: copy it, reload and try again.' });
@@ -108,27 +108,27 @@ function createEditor(ctx) {
@@ -108,27 +108,27 @@ function createEditor(ctx) {
}
}
});
});
- define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, B('trade.context.publish'), (req, res) => {
+ define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, B('trade.context.publish'), async (req, res) => {
try {
try {
- const i = mustInstrument(req);
+ const i = await mustInstrument(req);
const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';
const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';
- context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: decisionValue, note: req.body.note || null });
+ await context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: decisionValue, note: req.body.note || null });
common.after(res, editPath(i), { n: decisionValue === 'approved' ? 'context_published' : 'context_rejected' });
common.after(res, editPath(i), { n: decisionValue === 'approved' ? 'context_published' : 'context_rejected' });
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
});
- define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, B('trade.context.publish'), (req, res) => {
+ define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, B('trade.context.publish'), async (req, res) => {
try {
try {
- const i = mustInstrument(req);
- context.publish(i, parseInt(req.params.n, 10));
+ const i = await mustInstrument(req);
+ await context.publish(i, parseInt(req.params.n, 10));
common.after(res, editPath(i), { n: 'context_published' });
common.after(res, editPath(i), { n: 'context_published' });
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
});
- define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, B('trade.context.publish'), (req, res) => {
+ define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, B('trade.context.publish'), async (req, res) => {
try {
try {
- const i = mustInstrument(req);
- context.retract(req.viewer, i);
+ const i = await mustInstrument(req);
+ await context.retract(req.viewer, i);
common.after(res, editPath(i), { n: 'context_retracted' });
common.after(res, editPath(i), { n: 'context_retracted' });
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
});
@@ -30,25 +30,25 @@ function createPages(ctx) {
@@ -30,25 +30,25 @@ function createPages(ctx) {
title: 'What shipped on OpenVibe.Trade', canonical: urls.abs('/updates'), decision: listing('/updates'),
title: 'What shipped on OpenVibe.Trade', canonical: urls.abs('/updates'), decision: listing('/updates'),
body: frame.updatesBody({ service: 'trade', siteName: 'OpenVibe.Trade' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'trade', siteName: 'OpenVibe.Trade' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}));
}));
- define(router, 'get', '/', 'showHome', (req, res) => {
+ define(router, 'get', '/', 'showHome', async (req, res) => {
const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
const per = 50;
const per = 50;
- const first = instruments.page({ limit: 0, offset: 0 });
+ const first = await instruments.page({ limit: 0, offset: 0 });
const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });
const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });
if (pager.outOfRange && first.total) return common.notFound(req, res);
if (pager.outOfRange && first.total) return common.notFound(req, res);
- const page = instruments.page({ limit: pager.limit, offset: pager.offset });
- const recent = documents.recent(10).map((d) => ({ document: documents.dto(d), instrument: instruments.get(d.instrument_id) }));
+ const page = await instruments.page({ limit: pager.limit, offset: pager.offset });
+ const recent = (await Promise.all((await documents.recent(10)).map(async (d) => ({ document: documents.dto(d), instrument: await instruments.get(d.instrument_id) }))));
const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);
const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);
common.page(req, res, {
common.page(req, res, {
title: null, canonical, decision: listing(pageNo === 1 ? '/' : `/?page=${pageNo}`),
title: null, canonical, decision: listing(pageNo === 1 ? '/' : `/?page=${pageNo}`),
feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],
jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],
jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],
- body: views.home({ page, recent, urls, sync: sync.state(), pager }),
+ body: views.home({ page, recent, urls, sync: await sync.state(), pager }),
});
});
});
});
- define(router, 'get', '/resolve', 'showResolve', (req, res) => {
- const result = instruments.resolve(req.query.q);
+ define(router, 'get', '/resolve', 'showResolve', async (req, res) => {
+ const result = await instruments.resolve(req.query.q);
if (result.status === 'resolved') {
if (result.status === 'resolved') {
common.vary(res);
common.vary(res);
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
@@ -57,20 +57,20 @@ function createPages(ctx) {
@@ -57,20 +57,20 @@ function createPages(ctx) {
common.page(req, res, { status: result.status === 'not_found' ? 404 : 200, title: 'Find an instrument', body: views.resolvePage({ result, urls }), personal: true });
common.page(req, res, { status: result.status === 'not_found' ? 404 : 200, title: 'Find an instrument', body: views.resolvePage({ result, urls }), personal: true });
});
});
- define(router, 'get', '/sources', 'showSources', (req, res) => {
+ define(router, 'get', '/sources', 'showSources', async (req, res) => {
common.page(req, res, {
common.page(req, res, {
title: 'Source freshness', canonical: urls.abs('/sources'),
title: 'Source freshness', canonical: urls.abs('/sources'),
- body: views.sourcesPage({ sources: freshness.all(), sync: sync.state() }),
+ body: views.sourcesPage({ sources: await freshness.all(), sync: await sync.state() }),
});
});
});
});
- define(router, 'get', '/i/:symbol', 'showInstrument', (req, res) => {
+ define(router, 'get', '/i/:symbol', 'showInstrument', async (req, res) => {
let raw = String(req.params.symbol);
let raw = String(req.params.symbol);
const asJson = raw.endsWith('.json');
const asJson = raw.endsWith('.json');
if (asJson) raw = raw.slice(0, -5);
if (asJson) raw = raw.slice(0, -5);
- let instrument = instruments.bySymbol(raw);
+ let instrument = await instruments.bySymbol(raw);
if (!instrument) {
if (!instrument) {
- const r = instruments.resolve(raw, { kind: 'ticker' });
+ const r = await instruments.resolve(raw, { kind: 'ticker' });
instrument = r.status === 'resolved' ? r.instrument : null;
instrument = r.status === 'resolved' ? r.instrument : null;
}
}
if (!instrument) return asJson ? res.status(404).set('Cache-Control', 'private, no-store').json({ code: 'instrument.not_found', error: 'No such instrument' }) : common.notFound(req, res, `No instrument has the symbol “${raw}”.`);
if (!instrument) return asJson ? res.status(404).set('Cache-Control', 'private, no-store').json({ code: 'instrument.not_found', error: 'No such instrument' }) : common.notFound(req, res, `No instrument has the symbol “${raw}”.`);
@@ -81,10 +81,10 @@ function createPages(ctx) {
@@ -81,10 +81,10 @@ function createPages(ctx) {
}
}
const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
- const total = documents.count(instrument);
+ const total = await documents.count(instrument);
const pager = paginate({ page: pageNo, perPage: 25, total, href: (p) => (p === 1 ? urls.path.instrument(instrument) : `${urls.path.instrument(instrument)}?page=${p}`) });
const pager = paginate({ page: pageNo, perPage: 25, total, href: (p) => (p === 1 ? urls.path.instrument(instrument) : `${urls.path.instrument(instrument)}?page=${p}`) });
if (pager.outOfRange && total) return common.notFound(req, res);
if (pager.outOfRange && total) return common.notFound(req, res);
- const data = reading.instrument(instrument, { limit: pager.limit, offset: pager.offset });
+ const data = await reading.instrument(instrument, { limit: pager.limit, offset: pager.offset });
const { decision } = data;
const { decision } = data;
if (asJson) {
if (asJson) {
@@ -105,8 +105,8 @@ function createPages(ctx) {
@@ -105,8 +105,8 @@ function createPages(ctx) {
personal: Boolean(signedIn),
personal: Boolean(signedIn),
body: views.instrumentPage({
body: views.instrumentPage({
...data, instrument, urls, pager, notice: common.noticeOf(req),
...data, instrument, urls, pager, notice: common.noticeOf(req),
- context: data.context, pending: viewer.editor ? context.pending(instrument) : [],
- viewer, watchlists: signedIn ? watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),
+ context: data.context, pending: viewer.editor ? await context.pending(instrument) : [],
+ viewer, watchlists: signedIn ? await watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),
}),
}),
});
});
});
});
@@ -34,68 +34,68 @@ function createPrivate(ctx) {
@@ -34,68 +34,68 @@ function createPrivate(ctx) {
common.after(res, fallback, { e: e.message });
common.after(res, fallback, { e: e.message });
}
}
- function instrumentFrom(body) {
- if (body.symbol) return instruments.bySymbol(body.symbol);
- const r = instruments.resolve(body.q);
+ async function instrumentFrom(body) {
+ if (body.symbol) return await instruments.bySymbol(body.symbol);
+ const r = await instruments.resolve(body.q);
return r.status === 'resolved' ? r.instrument : null;
return r.status === 'resolved' ? r.instrument : null;
}
}
- define(router, 'get', '/watchlists', 'showWatchlists', (req, res) => {
+ define(router, 'get', '/watchlists', 'showWatchlists', async (req, res) => {
const v = req.viewer;
const v = req.viewer;
const signedIn = v.kind === 'user' && v.subject;
const signedIn = v.kind === 'user' && v.subject;
- const lists = signedIn ? watchlists.forOwner(v.subject).map((w) => watchlists.dto(w)) : [];
+ const lists = signedIn ? (await Promise.all((await watchlists.forOwner(v.subject)).map(async (w) => await watchlists.dto(w)))) : [];
common.page(req, res, {
common.page(req, res, {
title: 'Your watchlists', decision: privateDecision, personal: true,
title: 'Your watchlists', decision: privateDecision, personal: true,
body: views.watchlistsPage({
body: views.watchlistsPage({
- viewer: v, lists, rules: signedIn ? alerts.forOwner(v.subject) : [], deliveries: signedIn ? alerts.deliveries(v.subject, 20) : [],
+ viewer: v, lists, rules: signedIn ? await alerts.forOwner(v.subject) : [], deliveries: signedIn ? await alerts.deliveries(v.subject, 20) : [],
csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),
csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),
}),
}),
});
});
});
});
- define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), (req, res) => {
+ define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), async (req, res) => {
try {
try {
- watchlists.create(req.viewer.subject, { name: req.body.name });
+ await watchlists.create(req.viewer.subject, { name: req.body.name });
common.after(res, '/watchlists', { n: 'watchlist_created' });
common.after(res, '/watchlists', { n: 'watchlist_created' });
} catch (err) { back(req, res, '/watchlists', err); }
} catch (err) { back(req, res, '/watchlists', err); }
});
});
- define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
+ define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
try {
try {
- watchlists.rename(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });
+ await watchlists.rename(await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });
common.after(res, '/watchlists', { n: 'watchlist_renamed' });
common.after(res, '/watchlists', { n: 'watchlist_renamed' });
} catch (err) { back(req, res, '/watchlists', err); }
} catch (err) { back(req, res, '/watchlists', err); }
});
});
- define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
+ define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
try {
try {
- watchlists.remove(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));
+ await watchlists.remove(await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));
common.after(res, '/watchlists', { n: 'watchlist_deleted' });
common.after(res, '/watchlists', { n: 'watchlist_deleted' });
} catch (err) { back(req, res, '/watchlists', err); }
} catch (err) { back(req, res, '/watchlists', err); }
});
});
- define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
+ define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
try {
try {
- const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
- const instrument = instrumentFrom(req.body);
- const added = watchlists.add(w, instrument, req.body.note);
+ const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
+ const instrument = await instrumentFrom(req.body);
+ const added = await watchlists.add(w, instrument, req.body.note);
const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';
const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';
common.after(res, from, { n: added ? 'item_added' : 'item_present' });
common.after(res, from, { n: added ? 'item_added' : 'item_present' });
} catch (err) { back(req, res, '/watchlists', err); }
} catch (err) { back(req, res, '/watchlists', err); }
});
});
- define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
+ define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), async (req, res) => {
try {
try {
- const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
- watchlists.drop(w, instruments.bySymbol(req.body.symbol));
+ const w = await watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
+ await watchlists.drop(w, await instruments.bySymbol(req.body.symbol));
common.after(res, '/watchlists', { n: 'item_removed' });
common.after(res, '/watchlists', { n: 'item_removed' });
} catch (err) { back(req, res, '/watchlists', err); }
} catch (err) { back(req, res, '/watchlists', err); }
});
});
- define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, B('trade.alert.create'), (req, res) => {
- const instrument = instruments.bySymbol(req.body.symbol);
+ define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, B('trade.alert.create'), async (req, res) => {
+ const instrument = await instruments.bySymbol(req.body.symbol);
try {
try {
- alerts.create(req.viewer.subject, instrument, {
+ await alerts.create(req.viewer.subject, instrument, {
kind: req.body.kind, metric: req.body.metric, operator: req.body.operator, threshold: req.body.threshold,
kind: req.body.kind, metric: req.body.metric, operator: req.body.operator, threshold: req.body.threshold,
unit: req.body.unit, currency: req.body.currency, form_types: req.body.form_types,
unit: req.body.unit, currency: req.body.currency, form_types: req.body.form_types,
});
});
@@ -107,9 +107,9 @@ function createPrivate(ctx) {
@@ -107,9 +107,9 @@ function createPrivate(ctx) {
}
}
});
});
- define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, B('trade.alert.delete'), (req, res) => {
+ define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, B('trade.alert.delete'), async (req, res) => {
try {
try {
- alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));
+ await alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));
common.after(res, '/watchlists', { n: 'alert_deleted' });
common.after(res, '/watchlists', { n: 'alert_deleted' });
} catch (err) { back(req, res, '/watchlists', err); }
} catch (err) { back(req, res, '/watchlists', err); }
});
});
@@ -7,11 +7,12 @@
@@ -7,11 +7,12 @@
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
-const { app, ctx } = createApp();
+(async () => {
+const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
- console.log(`[Trade] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Trade] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Trade] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; Sources sync ${ctx.sync.enabled ? 'on' : 'off'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
console.log(`[Trade] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; Sources sync ${ctx.sync.enabled ? 'on' : 'off'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
@@ -23,10 +24,11 @@ function shutdown(signal) {
@@ -23,10 +24,11 @@ function shutdown(signal) {
ctx.worker.stop();
ctx.worker.stop();
server.close(async () => {
server.close(async () => {
try { await ctx.outbox.stop(); } catch { /* best effort */ }
try { await ctx.outbox.stop(); } catch { /* best effort */ }
- try { ctx.store.close(); } catch { /* already closed */ }
+ try { await ctx.store.close(); } catch { /* already closed */ }
process.exit(0);
process.exit(0);
});
});
setTimeout(() => process.exit(0), 5000).unref();
setTimeout(() => process.exit(0), 5000).unref();
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
+})().catch((err) => { console.error('[Trade] failed to start:', err); process.exit(1); });
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
const { CHARTER_TABLES } = require('./db');
const { CHARTER_TABLES } = require('./db');
-function createTradeReadiness({ store, auth, outbox, sync, freshness, release = null }) {
+function createTradeReadiness({ store, auth, outbox, sync, freshness, valkey = null, release = null }) {
const { db } = store;
const { db } = store;
return createReadiness({
return createReadiness({
service: 'trade',
service: 'trade',
@@ -21,12 +21,16 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
@@ -21,12 +21,16 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
checks: [
checks: [
{
{
name: 'db', required: true,
name: 'db', required: true,
- check: () => {
- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
+ check: async () => {
+ // A real round trip that names the store (postgresql / pglite), and the charter tables present.
+ const r = await db.ready();
+ if (!r.ok) return r.error;
+ const names = new Set((await db.prepare('SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()').all()).map((x) => x.name));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
- return missing.length ? `missing ${missing.join(', ')}` : true;
+ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
},
},
},
},
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
@@ -37,8 +41,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
@@ -37,8 +41,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
},
},
{
{
name: 'events_relay', required: false,
name: 'events_relay', required: false,
- check: () => {
- const s = outbox.status();
+ check: async () => {
+ const s = await outbox.status();
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
return { ok: true, detail: { pending: s.pending } };
return { ok: true, detail: { pending: s.pending } };
@@ -46,8 +50,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
@@ -46,8 +50,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
},
},
{
{
name: 'sources_sync', required: false,
name: 'sources_sync', required: false,
- check: () => {
- const s = sync.state();
+ check: async () => {
+ const s = await sync.state();
if (!s.enabled) return 'Sources sync off (OV_OAUTH_CLIENT_SECRET unset): no new filings or observations arrive';
if (!s.enabled) return 'Sources sync off (OV_OAUTH_CLIENT_SECRET unset): no new filings or observations arrive';
if (s.last_error) return `last Sources sync failed: ${s.last_error}`;
if (s.last_error) return `last Sources sync failed: ${s.last_error}`;
if (!s.last_ok_at) return 'Sources sync has not completed yet';
if (!s.last_ok_at) return 'Sources sync has not completed yet';
@@ -56,8 +60,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
@@ -56,8 +60,8 @@ function createTradeReadiness({ store, auth, outbox, sync, freshness, release =
},
},
{
{
name: 'freshness', required: false,
name: 'freshness', required: false,
- check: () => {
- const all = freshness.all();
+ check: async () => {
+ const all = await freshness.all();
const stale = all.filter((s) => s.stale).map((s) => s.key);
const stale = all.filter((s) => s.stale).map((s) => s.key);
return stale.length ? `${stale.length} of ${all.length} sources stale: ${stale.slice(0, 10).join(', ')}` : { ok: true, detail: { sources: all.length } };
return stale.length ? `${stale.length} of ${all.length} sources stale: ${stale.slice(0, 10).join(', ')}` : { ok: true, detail: { sources: all.length } };
},
},
@@ -275,8 +275,8 @@ ${pending.length ? html`<ul>${pending.map((p) => html`<li><a href="/editor/i/${e
@@ -275,8 +275,8 @@ ${pending.length ? html`<ul>${pending.map((p) => html`<li><a href="/editor/i/${e
<ul>${instruments.map((i) => html`<li><a href="/editor/i/${encodeURIComponent(i.symbol)}">${i.symbol}</a> — ${i.name}</li>`)}</ul>`;
<ul>${instruments.map((i) => html`<li><a href="/editor/i/${encodeURIComponent(i.symbol)}">${i.symbol}</a> — ${i.name}</li>`)}</ul>`;
}
}
-function editorInstrument({ instrument, aliases, revisions, documents, observations, csrf, notice: n, kinds, head }) {
- const revs = revisions.map((r) => html`<li class="card">
+async function editorInstrument({ instrument, aliases, revisions, documents, observations, csrf, notice: n, kinds, head }) {
+ const revs = await revisions.map((r) => html`<li class="card">
<p><strong>Revision ${r.revision}</strong> ${r.published ? html`<span class="badge fresh">published</span>` : ''} ${r.disclosure ? html`<span class="badge">${r.disclosure.short}</span>` : html`<span class="badge">editor</span>`}
<p><strong>Revision ${r.revision}</strong> ${r.published ? html`<span class="badge fresh">published</span>` : ''} ${r.disclosure ? html`<span class="badge">${r.disclosure.short}</span>` : html`<span class="badge">editor</span>`}
${r.needs_review ? html`<span class="badge stale">needs a person’s review</span>` : ''} · written ${when(r.written_at)} · as of ${r.as_of ? when(r.as_of) : 'no dated citation'}</p>
${r.needs_review ? html`<span class="badge stale">needs a person’s review</span>` : ''} · written ${when(r.written_at)} · as of ${r.as_of ? when(r.as_of) : 'no dated citation'}</p>
<div class="context-body">${raw(r.body_html)}</div>
<div class="context-body">${raw(r.body_html)}</div>
@@ -13,10 +13,10 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console
@@ -13,10 +13,10 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console
let syncTimer = null;
let syncTimer = null;
let freshTimer = null;
let freshTimer = null;
- function freshnessTick() {
+ async function freshnessTick() {
try {
try {
- const changed = freshness.evaluateAll();
- const docs = indexing.refreshAll();
+ const changed = await freshness.evaluateAll();
+ const docs = await indexing.refreshAll();
if (changed.length || docs.length) outbox.kick();
if (changed.length || docs.length) outbox.kick();
return { sources: changed.length, documents: docs.length };
return { sources: changed.length, documents: docs.length };
} catch (err) {
} catch (err) {
@@ -53,17 +53,17 @@ const { boot, check, done } = require('./helpers/boot');
@@ -53,17 +53,17 @@ const { boot, check, done } = require('./helpers/boot');
await check('watchlists: 10 a minute per person, shared by the API and the form; nothing stored past it', async () => {
await check('watchlists: 10 a minute per person, shared by the API and the form; nothing stored past it', async () => {
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
- const count = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlists').get().n;
+ const count = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlists').get()).n;
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 10; i++) {
const r = await t.get('/api/v1/watchlists', { as: alice, json: { name: `List ${i}` } });
const r = await t.get('/api/v1/watchlists', { as: alice, json: { name: `List ${i}` } });
assert.strictEqual(r.status, 201, `watchlist ${i + 1}: ${r.text}`);
assert.strictEqual(r.status, 201, `watchlist ${i + 1}: ${r.text}`);
}
}
- const before = count();
+ const before = await count();
const form = await t.get('/watchlists', { as: alice, form: { _csrf: t.csrf(alice), name: 'One too many' } });
const form = await t.get('/watchlists', { as: alice, form: { _csrf: t.csrf(alice), name: 'One too many' } });
assert.deepStrictEqual([form.status, form.json().code, form.headers.get('retry-after')], [429, 'rate_limited', '60']);
assert.deepStrictEqual([form.status, form.json().code, form.headers.get('retry-after')], [429, 'rate_limited', '60']);
const viaService = await t.get('/api/v1/watchlists', { as: svc, headers: { 'x-ov-subject': alice.subject }, json: { name: 'Relayed' } });
const viaService = await t.get('/api/v1/watchlists', { as: svc, headers: { 'x-ov-subject': alice.subject }, json: { name: 'Relayed' } });
assert.strictEqual(viaService.status, 429, 'a service creating for the person shares their budget');
assert.strictEqual(viaService.status, 429, 'a service creating for the person shares their budget');
- assert.strictEqual(count(), before, 'nothing stored');
+ assert.strictEqual(await count(), before, 'nothing stored');
const other = await t.get('/api/v1/watchlists', { as: bob, json: { name: 'Bob list' } });
const other = await t.get('/api/v1/watchlists', { as: bob, json: { name: 'Bob list' } });
assert.strictEqual(other.status, 201, `another person still creates: ${other.text}`);
assert.strictEqual(other.status, 201, `another person still creates: ${other.text}`);
});
});
@@ -11,10 +11,10 @@ const MIN = 60e3;
@@ -11,10 +11,10 @@ const MIN = 60e3;
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
const iso = t.iso;
const iso = t.iso;
- const obs = (value, minutes, ref) => t.observe(acme, { metric: 'price.close', value, source_ref: ref, observed_at: iso(t.T0 + minutes * MIN), retrieved_at: iso(t.T0 + minutes * MIN) });
- const deliveries = () => t.ctx.store.db.prepare('SELECT * FROM trade_alert_deliveries ORDER BY created_at, id').all();
+ const obs = async (value, minutes, ref) => await t.observe(acme, { metric: 'price.close', value, source_ref: ref, observed_at: iso(t.T0 + minutes * MIN), retrieved_at: iso(t.T0 + minutes * MIN) });
+ const deliveries = async () => await t.ctx.store.db.prepare('SELECT * FROM trade_alert_deliveries ORDER BY created_at, id').all();
let ruleId;
let ruleId;
await check('alice creates a threshold rule through the API (for herself only)', async () => {
await check('alice creates a threshold rule through the API (for herself only)', async () => {
@@ -26,14 +26,14 @@ const MIN = 60e3;
@@ -26,14 +26,14 @@ const MIN = 60e3;
});
});
await check('an observation crossing the threshold delivers exactly once, with one event', async () => {
await check('an observation crossing the threshold delivers exactly once, with one event', async () => {
- obs('99.50', 1, 'o1');
- assert.strictEqual(deliveries().length, 0, 'below: nothing');
- const { observation } = obs('101.00', 2, 'o2');
- const d = deliveries();
+ await obs('99.50', 1, 'o1');
+ assert.strictEqual((await deliveries()).length, 0, 'below: nothing');
+ const { observation } = await obs('101.00', 2, 'o2');
+ const d = await deliveries();
assert.strictEqual(d.length, 1);
assert.strictEqual(d.length, 1);
assert.strictEqual(d[0].trigger_kind, 'observation');
assert.strictEqual(d[0].trigger_kind, 'observation');
assert.strictEqual(d[0].trigger_id, observation.id);
assert.strictEqual(d[0].trigger_id, observation.id);
- const ev = t.events('trade.alert.triggered');
+ const ev = await t.events('trade.alert.triggered');
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev[0].visibility, 'subject');
assert.strictEqual(ev[0].visibility, 'subject');
assert.deepStrictEqual(ev[0].subject, { type: 'user', id: t.alice.subject });
assert.deepStrictEqual(ev[0].subject, { type: 'user', id: t.alice.subject });
@@ -44,42 +44,42 @@ const MIN = 60e3;
@@ -44,42 +44,42 @@ const MIN = 60e3;
});
});
await check('replaying the same observation (same source reference) delivers nothing new', async () => {
await check('replaying the same observation (same source reference) delivers nothing new', async () => {
- const r = obs('101.00', 2, 'o2');
+ const r = await obs('101.00', 2, 'o2');
assert.strictEqual(r.created, false);
assert.strictEqual(r.created, false);
const api = await t.get('/api/v1/observations', { as: t.network.serviceToken('feed', ['trade.observation.write']), json: { symbol: 'ACME', metric: 'price.close', value: '101.00', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'o2', observed_at: iso(t.T0 + 2 * MIN), retrieved_at: iso(t.T0 + 2 * MIN) } });
const api = await t.get('/api/v1/observations', { as: t.network.serviceToken('feed', ['trade.observation.write']), json: { symbol: 'ACME', metric: 'price.close', value: '101.00', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'o2', observed_at: iso(t.T0 + 2 * MIN), retrieved_at: iso(t.T0 + 2 * MIN) } });
assert.strictEqual(api.status, 200);
assert.strictEqual(api.status, 200);
- assert.strictEqual(deliveries().length, 1);
- assert.strictEqual(t.events('trade.alert.triggered').length, 1);
+ assert.strictEqual((await deliveries()).length, 1);
+ assert.strictEqual((await t.events('trade.alert.triggered')).length, 1);
});
});
await check('re-running evaluation for the same observation cannot deliver twice (UNIQUE rule × trigger)', async () => {
await check('re-running evaluation for the same observation cannot deliver twice (UNIQUE rule × trigger)', async () => {
- const o = t.ctx.store.db.prepare("SELECT * FROM trade_market_observations WHERE source_ref = 'o2'").get();
- t.ctx.store.db.prepare('UPDATE trade_alert_rules SET armed = 1, last_observed_at = NULL WHERE id = ?').run(ruleId);
- t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));
- t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));
- assert.strictEqual(deliveries().length, 1);
- assert.strictEqual(t.events('trade.alert.triggered').length, 1);
+ const o = await t.ctx.store.db.prepare("SELECT * FROM trade_market_observations WHERE source_ref = 'o2'").get();
+ await t.ctx.store.db.prepare('UPDATE trade_alert_rules SET armed = 1, last_observed_at = NULL WHERE id = ?').run(ruleId);
+ await t.ctx.store.tx(async () => await t.ctx.alerts.onObservation(o, acme));
+ await t.ctx.store.tx(async () => await t.ctx.alerts.onObservation(o, acme));
+ assert.strictEqual((await deliveries()).length, 1);
+ assert.strictEqual((await t.events('trade.alert.triggered')).length, 1);
});
});
await check('staying above does not repeat; dropping below re-arms; crossing again delivers once more', async () => {
await check('staying above does not repeat; dropping below re-arms; crossing again delivers once more', async () => {
- obs('102.00', 3, 'o3');
- assert.strictEqual(deliveries().length, 1, 'still above: no repeat');
- obs('98.00', 4, 'o4');
- assert.strictEqual(deliveries().length, 1);
- obs('100.50', 5, 'o5');
- assert.strictEqual(deliveries().length, 2);
- assert.strictEqual(t.events('trade.alert.triggered').length, 2);
+ await obs('102.00', 3, 'o3');
+ assert.strictEqual((await deliveries()).length, 1, 'still above: no repeat');
+ await obs('98.00', 4, 'o4');
+ assert.strictEqual((await deliveries()).length, 1);
+ await obs('100.50', 5, 'o5');
+ assert.strictEqual((await deliveries()).length, 2);
+ assert.strictEqual((await t.events('trade.alert.triggered')).length, 2);
});
});
await check('a late observation (older than the last one evaluated) never fires', async () => {
await check('a late observation (older than the last one evaluated) never fires', async () => {
- obs('97.00', 6, 'o6'); // re-arms
- obs('150.00', 1.5, 'late');
- assert.strictEqual(deliveries().length, 2);
+ await obs('97.00', 6, 'o6'); // re-arms
+ await obs('150.00', 1.5, 'late');
+ assert.strictEqual((await deliveries()).length, 2);
});
});
await check('unit or currency mismatch never fires (no conversion)', async () => {
await check('unit or currency mismatch never fires (no conversion)', async () => {
- t.observe(acme, { metric: 'price.close', value: '500', unit: 'EUR', currency: 'EUR', source_ref: 'eur', observed_at: iso(t.T0 + 7 * MIN), retrieved_at: iso(t.T0 + 7 * MIN) });
- assert.strictEqual(deliveries().length, 2);
+ await t.observe(acme, { metric: 'price.close', value: '500', unit: 'EUR', currency: 'EUR', source_ref: 'eur', observed_at: iso(t.T0 + 7 * MIN), retrieved_at: iso(t.T0 + 7 * MIN) });
+ assert.strictEqual((await deliveries()).length, 2);
});
});
await check('document rules: new_document and filing_type fire once per document; revisions of the item never re-fire', async () => {
await check('document rules: new_document and filing_type fire once per document; revisions of the item never re-fire', async () => {
@@ -90,17 +90,17 @@ const MIN = 60e3;
@@ -90,17 +90,17 @@ const MIN = 60e3;
const url = 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm';
const url = 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm';
const item = t.sources.putItem({ canonical_url: url, title: 'ACME CORP', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
const item = t.sources.putItem({ canonical_url: url, title: 'ACME CORP', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
await t.ctx.sync.run();
await t.ctx.sync.run();
- const bobs = () => deliveries().filter((d) => d.owner_subject === t.bob.subject);
- assert.strictEqual(bobs().length, 1, 'new_document fired, filing_type(8-K) did not for a 10-Q');
+ const bobs = async () => (await deliveries()).filter((d) => d.owner_subject === t.bob.subject);
+ assert.strictEqual((await bobs()).length, 1, 'new_document fired, filing_type(8-K) did not for a 10-Q');
t.sources.putItem({ id: item.id, canonical_url: url, title: 'ACME CORP (amended title)', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
t.sources.putItem({ id: item.id, canonical_url: url, title: 'ACME CORP (amended title)', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
await t.ctx.sync.run();
await t.ctx.sync.run();
- t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();
+ await t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();
await t.ctx.sync.run();
await t.ctx.sync.run();
- assert.strictEqual(bobs().length, 1, 'item revision and page replay: no second delivery');
+ assert.strictEqual((await bobs()).length, 1, 'item revision and page replay: no second delivery');
assert.strictEqual((await t.get('/i/ACME.json')).json().documents[0].title, 'ACME CORP (amended title)');
assert.strictEqual((await t.get('/i/ACME.json')).json().documents[0].title, 'ACME CORP (amended title)');
t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
await t.ctx.sync.run();
await t.ctx.sync.run();
- assert.strictEqual(bobs().length, 3, 'an 8-K: new_document + filing_type');
+ assert.strictEqual((await bobs()).length, 3, 'an 8-K: new_document + filing_type');
});
});
await check('rules and deliveries are private: someone else sees none and cannot delete them', async () => {
await check('rules and deliveries are private: someone else sees none and cannot delete them', async () => {
@@ -13,9 +13,9 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -13,9 +13,9 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
- const { observation } = t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });
- t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
+ const { observation } = await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });
+ await t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });
const ai = t.network.serviceToken('ai', ['trade.context.read', 'trade.context.propose']);
const ai = t.network.serviceToken('ai', ['trade.context.read', 'trade.context.propose']);
let input;
let input;
@@ -60,14 +60,14 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -60,14 +60,14 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
assert.ok(!page.text.includes('latest recorded close for ACME'), 'the draft is not shown to readers');
assert.ok(!page.text.includes('latest recorded close for ACME'), 'the draft is not shown to readers');
assert.ok(page.text.includes('<meta name="robots" content="noindex, nofollow">'));
assert.ok(page.text.includes('<meta name="robots" content="noindex, nofollow">'));
assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));
assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));
- assert.strictEqual(t.events('trade.index_document.upserted').length, 0);
+ assert.strictEqual((await t.events('trade.index_document.upserted')).length, 0);
});
});
await check('AI output cannot be published without a person, nor delivered as if by a person', async () => {
await check('AI output cannot be published without a person, nor delivered as if by a person', async () => {
const pub = await t.get(`/editor/i/ACME/context/${draftRev}/publish`, { as: t.editor, form: { _csrf: t.csrf(t.editor) } });
const pub = await t.get(`/editor/i/ACME/context/${draftRev}/publish`, { as: t.editor, form: { _csrf: t.csrf(t.editor) } });
assert.strictEqual(pub.status, 303);
assert.strictEqual(pub.status, 303);
assert.ok(/review/i.test(errOf(pub)));
assert.ok(/review/i.test(errOf(pub)));
- assert.strictEqual(t.ctx.instruments.bySymbol('ACME').context_published_revision, null);
+ assert.strictEqual((await t.ctx.instruments.bySymbol('ACME')).context_published_revision, null);
const svcReview = await t.get(`/api/v1/instruments/ACME/context/revisions/${draftRev}/review`, { as: ai, json: { decision: 'approved' } });
const svcReview = await t.get(`/api/v1/instruments/ACME/context/revisions/${draftRev}/review`, { as: ai, json: { decision: 'approved' } });
assert.strictEqual(svcReview.status, 403, 'a service cannot review');
assert.strictEqual(svcReview.status, 403, 'a service cannot review');
const noRun = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context' }, output: { summary: 'x' } } });
const noRun = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context' }, output: { summary: 'x' } } });
@@ -101,7 +101,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -101,7 +101,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), page.text.match(/<meta name="robots"[^>]*>/)[0]);
assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), page.text.match(/<meta name="robots"[^>]*>/)[0]);
const sm = (await t.get('/sitemaps/instruments.xml')).text;
const sm = (await t.get('/sitemaps/instruments.xml')).text;
assert.ok(sm.includes('https://openvibe.trade/i/ACME'));
assert.ok(sm.includes('https://openvibe.trade/i/ACME'));
- const up = t.events('trade.index_document.upserted');
+ const up = await t.events('trade.index_document.upserted');
assert.strictEqual(up.length, 1);
assert.strictEqual(up.length, 1);
assert.strictEqual(up[0].payload.authorship, 'ai_generated');
assert.strictEqual(up[0].payload.authorship, 'ai_generated');
assert.strictEqual(up[0].payload.visibility, 'public');
assert.strictEqual(up[0].payload.visibility, 'public');
@@ -110,7 +110,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -110,7 +110,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
});
});
await check('an editor writes human context with a plain form; a stale base is refused without losing the published text', async () => {
await check('an editor writes human context with a plain form; a stale base is refused without losing the published text', async () => {
- const head = t.ctx.context.head(t.ctx.instruments.bySymbol('ACME')).number;
+ const head = (await t.ctx.context.head(await t.ctx.instruments.bySymbol('ACME'))).number;
const ok = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme filed nothing new this week. ${WORDS(70)}`, expected_revision: String(head), cite: `observation:${observation.id}`, publish: '1' } });
const ok = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme filed nothing new this week. ${WORDS(70)}`, expected_revision: String(head), cite: `observation:${observation.id}`, publish: '1' } });
assert.strictEqual(ok.status, 303);
assert.strictEqual(ok.status, 303);
assert.ok(/n=context_published/.test(ok.headers.get('location')));
assert.ok(/n=context_published/.test(ok.headers.get('location')));
@@ -126,7 +126,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -126,7 +126,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
const r = await t.get('/editor/i/ACME/context/retract', { as: t.editor, form: { _csrf: t.csrf(t.editor) } });
const r = await t.get('/editor/i/ACME/context/retract', { as: t.editor, form: { _csrf: t.csrf(t.editor) } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));
assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));
- assert.strictEqual(t.events('trade.index_document.deleted').length, 1);
+ assert.strictEqual((await t.events('trade.index_document.deleted')).length, 1);
});
});
await check('the editor is for editors only; its pages are private and noindex', async () => {
await check('the editor is for editors only; its pages are private and noindex', async () => {
@@ -138,7 +138,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -138,7 +138,7 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
assert.strictEqual(e.headers.get('x-robots-tag'), 'noindex, nofollow');
assert.strictEqual(e.headers.get('x-robots-tag'), 'noindex, nofollow');
const add = await t.get('/editor/instruments', { as: t.editor, form: { _csrf: t.csrf(t.editor), symbol: 'nwco', name: 'New Co', kind: 'equity', cik: '' } });
const add = await t.get('/editor/instruments', { as: t.editor, form: { _csrf: t.csrf(t.editor), symbol: 'nwco', name: 'New Co', kind: 'equity', cik: '' } });
assert.strictEqual(add.status, 303);
assert.strictEqual(add.status, 303);
- assert.ok(t.ctx.instruments.bySymbol('NWCO'));
+ assert.ok(await t.ctx.instruments.bySymbol('NWCO'));
});
});
await t.close();
await t.close();
@@ -50,16 +50,16 @@ const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
@@ -50,16 +50,16 @@ const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
await check('every envelope Trade actually produces validates as events.event-envelope@1 (and Search documents as search.index-document@1)', async () => {
await check('every envelope Trade actually produces validates as events.event-envelope@1 (and Search documents as search.index-document@1)', async () => {
const t = await boot();
const t = await boot();
- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
- t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 60 });
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ await t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 60 });
await t.get('/api/v1/alerts', { as: t.alice, json: { symbol: 'ACME', kind: 'threshold', metric: 'price.close', operator: 'above', threshold: '1', unit: 'USD' } });
await t.get('/api/v1/alerts', { as: t.alice, json: { symbol: 'ACME', kind: 'threshold', metric: 'price.close', operator: 'above', threshold: '1', unit: 'USD' } });
t.clock.advance(1000);
t.clock.advance(1000);
- t.observe(acme, { metric: 'price.close', value: '2', source_ref: 'x', observed_at: t.iso(t.clock.now() - 1000), retrieved_at: t.iso(t.clock.now()) });
+ await t.observe(acme, { metric: 'price.close', value: '2', source_ref: 'x', observed_at: t.iso(t.clock.now() - 1000), retrieved_at: t.iso(t.clock.now()) });
t.clock.advance(3600e3);
t.clock.advance(3600e3);
- t.ctx.freshness.evaluateAll();
- const rev = t.ctx.context.propose({ kind: 'user', subject: t.editor.subject, editor: true }, acme, { body: Array.from({ length: 80 }, () => 'word').join(' ') });
- t.ctx.context.publish(acme, rev.revision.number);
- const all = t.events();
+ await t.ctx.freshness.evaluateAll();
+ const rev = await t.ctx.context.propose({ kind: 'user', subject: t.editor.subject, editor: true }, acme, { body: Array.from({ length: 80 }, () => 'word').join(' ') });
+ await t.ctx.context.publish(acme, rev.revision.number);
+ const all = await t.events();
const types = new Set(all.map((e) => e.event_type));
const types = new Set(all.map((e) => e.event_type));
for (const ty of ['trade.observation.created', 'trade.alert.triggered', 'trade.source.stale', 'trade.index_document.upserted']) assert.ok(types.has(ty), `${ty} produced`);
for (const ty of ['trade.observation.created', 'trade.alert.triggered', 'trade.source.stale', 'trade.index_document.upserted']) assert.ok(types.has(ty), `${ty} produced`);
for (const e of all) {
for (const e of all) {
@@ -15,7 +15,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
@@ -15,7 +15,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
const t = await boot();
const t = await boot();
const caps = ['trade.watchlist.read', 'trade.watchlist.create'];
const caps = ['trade.watchlist.read', 'trade.watchlist.create'];
const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.trade'], cap: caps, extra });
const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.trade'], cap: caps, extra });
- const secret = t.ctx.watchlists.create(t.alice.subject, { name: 'Alice secret plans' });
+ const secret = await t.ctx.watchlists.create(t.alice.subject, { name: 'Alice secret plans' });
await check('an app or module cannot read or write someone else\'s watchlists by naming them in X-OV-Subject', async () => {
await check('an app or module cannot read or write someone else\'s watchlists by naming them in X-OV-Subject', async () => {
for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
@@ -30,7 +30,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
@@ -30,7 +30,7 @@ const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
assert.strictEqual(write.status, 403);
assert.strictEqual(write.status, 403);
}
}
}
}
- assert.deepStrictEqual(t.ctx.watchlists.forOwner(t.alice.subject).map((w) => w.name), ['Alice secret plans']);
+ assert.deepStrictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).map((w) => w.name), ['Alice secret plans']);
});
});
await check('an app acts for its on_behalf_of person (the header is optional and must match)', async () => {
await check('an app acts for its on_behalf_of person (the header is optional and must match)', async () => {
@@ -10,12 +10,12 @@ const HOUR = 3600e3;
@@ -10,12 +10,12 @@ const HOUR = 3600e3;
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
const iso = t.iso;
const iso = t.iso;
await check('an observation from a source with a known window is current inside the window', async () => {
await check('an observation from a source with a known window is current inside the window', async () => {
- t.ctx.freshness.report('test-feed', { name: 'Test feed', status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 3600 });
- t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: iso(t.T0 - 60e3), retrieved_at: iso(t.T0) });
+ await t.ctx.freshness.report('test-feed', { name: 'Test feed', status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 3600 });
+ await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: iso(t.T0 - 60e3), retrieved_at: iso(t.T0) });
const json = (await t.get('/i/ACME.json')).json();
const json = (await t.get('/i/ACME.json')).json();
assert.strictEqual(json.observations[0].freshness.stale, false);
assert.strictEqual(json.observations[0].freshness.stale, false);
assert.strictEqual(json.sources[0].status, 'fresh');
assert.strictEqual(json.sources[0].status, 'fresh');
@@ -41,22 +41,22 @@ const HOUR = 3600e3;
@@ -41,22 +41,22 @@ const HOUR = 3600e3;
});
});
await check('the transition emits trade.source.stale exactly once, and recovery emits trade.source.recovered once', async () => {
await check('the transition emits trade.source.stale exactly once, and recovery emits trade.source.recovered once', async () => {
- t.ctx.freshness.evaluateAll();
- t.ctx.freshness.evaluateAll();
- const stale = t.events('trade.source.stale');
+ await t.ctx.freshness.evaluateAll();
+ await t.ctx.freshness.evaluateAll();
+ const stale = await t.events('trade.source.stale');
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale.length, 1);
assert.strictEqual(stale[0].payload.stale_since, iso(t.T0 + HOUR));
assert.strictEqual(stale[0].payload.stale_since, iso(t.T0 + HOUR));
- t.observe(acme, { metric: 'price.close', value: '10.60', source_ref: 'b', observed_at: iso(t.clock.now() - 60e3), retrieved_at: iso(t.clock.now()) });
- t.ctx.freshness.evaluateAll();
- assert.strictEqual(t.events('trade.source.recovered').length, 1);
- assert.strictEqual(t.events('trade.source.stale').length, 1);
+ await t.observe(acme, { metric: 'price.close', value: '10.60', source_ref: 'b', observed_at: iso(t.clock.now() - 60e3), retrieved_at: iso(t.clock.now()) });
+ await t.ctx.freshness.evaluateAll();
+ assert.strictEqual((await t.events('trade.source.recovered')).length, 1);
+ assert.strictEqual((await t.events('trade.source.stale')).length, 1);
const json = (await t.get('/i/ACME.json')).json();
const json = (await t.get('/i/ACME.json')).json();
assert.strictEqual(json.observations[0].value, '10.60');
assert.strictEqual(json.observations[0].value, '10.60');
assert.strictEqual(json.observations[0].freshness.stale, false);
assert.strictEqual(json.observations[0].freshness.stale, false);
});
});
await check('a value older than its own max_age is stale even while its source is fresh', async () => {
await check('a value older than its own max_age is stale even while its source is fresh', async () => {
- t.observe(acme, { metric: 'eps.diluted', value: '1.23', unit: 'USD/share', source_ref: 'c', max_age_sec: 3600, observed_at: iso(t.clock.now() - 2 * HOUR), retrieved_at: iso(t.clock.now()) });
+ await t.observe(acme, { metric: 'eps.diluted', value: '1.23', unit: 'USD/share', source_ref: 'c', max_age_sec: 3600, observed_at: iso(t.clock.now() - 2 * HOUR), retrieved_at: iso(t.clock.now()) });
const o = (await t.get('/i/ACME.json')).json().observations.find((x) => x.metric === 'eps.diluted');
const o = (await t.get('/i/ACME.json')).json().observations.find((x) => x.metric === 'eps.diluted');
assert.strictEqual(o.freshness.stale, true);
assert.strictEqual(o.freshness.stale, true);
assert.strictEqual(o.freshness.reason, 'value_older_than_max_age');
assert.strictEqual(o.freshness.reason, 'value_older_than_max_age');
@@ -64,19 +64,19 @@ const HOUR = 3600e3;
@@ -64,19 +64,19 @@ const HOUR = 3600e3;
});
});
await check('a source Trade has never heard from is "freshness unknown", never "current"', async () => {
await check('a source Trade has never heard from is "freshness unknown", never "current"', async () => {
- const v = t.ctx.freshness.view('nobody-knows');
+ const v = await t.ctx.freshness.view('nobody-knows');
assert.strictEqual(v.status, 'unknown');
assert.strictEqual(v.status, 'unknown');
assert.strictEqual(v.stale, true);
assert.strictEqual(v.stale, true);
});
});
await check('Sources unavailable: the sync records the failure and invents nothing; data decays to stale on Trade\'s clock', async () => {
await check('Sources unavailable: the sync records the failure and invents nothing; data decays to stale on Trade\'s clock', async () => {
- const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;
+ const before = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n;
t.sources.setDown(true);
t.sources.setDown(true);
const r = await t.ctx.sync.run();
const r = await t.ctx.sync.run();
assert.strictEqual(r.ok, false);
assert.strictEqual(r.ok, false);
- const state = t.ctx.sync.state();
+ const state = await t.ctx.sync.state();
assert.ok(state.last_error && /503/.test(state.last_error));
assert.ok(state.last_error && /503/.test(state.last_error));
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, before);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n, before);
t.clock.advance(5 * HOUR);
t.clock.advance(5 * HOUR);
const json = (await t.get('/i/ACME.json')).json();
const json = (await t.get('/i/ACME.json')).json();
assert.ok(json.observations.every((o) => o.freshness.stale), 'everything is stale now');
assert.ok(json.observations.every((o) => o.freshness.stale), 'everything is stale now');
@@ -89,10 +89,10 @@ const HOUR = 3600e3;
@@ -89,10 +89,10 @@ const HOUR = 3600e3;
});
});
await check('the gate: a monetary observation older than TRADE_PRICE_MAX_AGE_SEC makes the page noindex (stale_price)', async () => {
await check('the gate: a monetary observation older than TRADE_PRICE_MAX_AGE_SEC makes the page noindex (stale_price)', async () => {
- const acmeNow = t.ctx.instruments.bySymbol('ACME');
- assert.ok(!t.ctx.indexing.decide(acmeNow).codes.includes('stale_price'), 'observed hours ago: not yet');
+ const acmeNow = await t.ctx.instruments.bySymbol('ACME');
+ assert.ok(!(await t.ctx.indexing.decide(acmeNow)).codes.includes('stale_price'), 'observed hours ago: not yet');
t.clock.advance(24 * HOUR);
t.clock.advance(24 * HOUR);
- const d = t.ctx.indexing.decide(acmeNow);
+ const d = await t.ctx.indexing.decide(acmeNow);
assert.ok(d.codes.includes('stale_price'), d.codes.join(','));
assert.ok(d.codes.includes('stale_price'), d.codes.join(','));
assert.ok(!d.indexable);
assert.ok(!d.indexable);
});
});
@@ -48,11 +48,14 @@ async function boot(opts = {}) {
@@ -48,11 +48,14 @@ async function boot(opts = {}) {
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
+ const { createStore } = require('../../server/db');
+ // One database per boot (PGlite, or TRADE_TEST_STORE=pg: the containers); a restart keeps it, like a file did.
+ const testdb = await require('./db').testDb();
let server = null;
let server = null;
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = createApp({ config, now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
await built.ctx.auth.ensureKey();
await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;
@@ -61,7 +64,7 @@ async function boot(opts = {}) {
@@ -61,7 +64,7 @@ async function boot(opts = {}) {
}
}
async function stop() {
async function stop() {
if (server) await new Promise((r) => server.close(r));
if (server) await new Promise((r) => server.close(r));
- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
+ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }
server = null; built = null;
server = null; built = null;
}
}
@@ -78,8 +81,8 @@ async function boot(opts = {}) {
@@ -78,8 +81,8 @@ async function boot(opts = {}) {
return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
}
}
- function events(type = null) {
- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
+ async function events(type = null) {
+ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))
.filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
.filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
}
}
@@ -87,13 +90,13 @@ async function boot(opts = {}) {
@@ -87,13 +90,13 @@ async function boot(opts = {}) {
network, sources, clock, dbPath, editor, alice, bob, get, events, T0,
network, sources, clock, dbPath, editor, alice, bob, get, events, T0,
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.TRADE_FORM_SECRET }, user),
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.TRADE_FORM_SECRET }, user),
iso: (ms) => new Date(ms).toISOString(),
iso: (ms) => new Date(ms).toISOString(),
- instrument(input) { return t.ctx.instruments.create({ kind: 'equity', ...input }, editor.subject); },
+ async instrument(input) { return await t.ctx.instruments.create({ kind: 'equity', ...input }, editor.subject); },
/** Record an observation as a first-party feed would (domain call, recordedBy svc:feed). */
/** Record an observation as a first-party feed would (domain call, recordedBy svc:feed). */
- observe(instrument, input) {
- return t.ctx.observations.record({ source_key: 'test-feed', unit: 'USD', currency: 'USD', ...input }, instrument, { recordedBy: 'svc:feed' });
+ async observe(instrument, input) {
+ return await t.ctx.observations.record({ source_key: 'test-feed', unit: 'USD', currency: 'USD', ...input }, instrument, { recordedBy: 'svc:feed' });
},
},
async restart() { await stop(); await start(); },
async restart() { await stop(); await start(); },
- async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
+ async close() { await stop(); await testdb.close(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
};
};
await start();
await start();
return t;
return t;
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * TRADE_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.TRADE_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'trade', max });
+
+module.exports = { testDb, pgAvailable };
@@ -76,7 +76,7 @@ async function startSources({ network }) {
@@ -76,7 +76,7 @@ async function startSources({ network }) {
const calls = [];
const calls = [];
let seq = 0;
let seq = 0;
let down = false;
let down = false;
- const srv = await listen((req, raw, json) => {
+ const srv = await listen(async (req, raw, json) => {
calls.push({ method: req.method, url: req.url });
calls.push({ method: req.method, url: req.url });
if (down) return json(503, { code: 'down' });
if (down) return json(503, { code: 'down' });
const token = String(req.headers.authorization || '').slice(7);
const token = String(req.headers.authorization || '').slice(7);
@@ -97,7 +97,7 @@ async function startSources({ network }) {
@@ -97,7 +97,7 @@ async function startSources({ network }) {
const page = rows.slice(0, limit);
const page = rows.slice(0, limit);
const keys = [...new Set(page.map((i) => i.source_key))];
const keys = [...new Set(page.map((i) => i.source_key))];
const health = {};
const health = {};
- for (const k of keys) if (sources.has(k)) health[k] = sources.get(k).health;
+ for (const k of keys) if (sources.has(k)) health[k] = (await sources.get(k)).health;
return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rows.length > limit, sources: health });
return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rows.length > limit, sources: health });
}
}
return json(404, { code: 'route.not_found' });
return json(404, { code: 'route.not_found' });
@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -9,7 +9,7 @@ const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const feed = t.network.serviceToken('feed', ['trade.observation.write']);
const feed = t.network.serviceToken('feed', ['trade.observation.write']);
- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
await check('an instrument without observations shows no number, in HTML and JSON', async () => {
await check('an instrument without observations shows no number, in HTML and JSON', async () => {
const page = await t.get('/i/ACME');
const page = await t.get('/i/ACME');
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(future.status, 422);
assert.strictEqual(future.status, 422);
const noValue = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: 'about ten', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0), retrieved_at: t.iso(t.T0) } });
const noValue = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: 'about ten', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0), retrieved_at: t.iso(t.T0) } });
assert.strictEqual(noValue.status, 422);
assert.strictEqual(noValue.status, 422);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n, 0);
});
});
await check('only a service with trade.observation.write records observations; people cannot', async () => {
await check('only a service with trade.observation.write records observations; people cannot', async () => {
@@ -61,7 +61,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -61,7 +61,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(json.observations.length, 1);
assert.strictEqual(json.observations.length, 1);
for (const k of ['observed_at', 'retrieved_at', 'recorded_at']) assert.ok(json.observations[0][k], k);
for (const k of ['observed_at', 'retrieved_at', 'recorded_at']) assert.ok(json.observations[0][k], k);
assert.ok(json.observations[0].freshness, 'every observation carries its freshness');
assert.ok(json.observations[0].freshness, 'every observation carries its freshness');
- const ev = t.events('trade.observation.created');
+ const ev = await t.events('trade.observation.created');
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev[0].payload.observed_at, '2026-09-22T11:59:00.000Z');
assert.strictEqual(ev[0].payload.observed_at, '2026-09-22T11:59:00.000Z');
assert.strictEqual(ev[0].payload.retrieved_at, '2026-09-22T12:00:00.000Z');
assert.strictEqual(ev[0].payload.retrieved_at, '2026-09-22T12:00:00.000Z');
@@ -74,16 +74,16 @@ const { boot, check, done } = require('./helpers/boot');
@@ -74,16 +74,16 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(same.json().observation.id, obsId);
assert.strictEqual(same.json().observation.id, obsId);
const diff = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '11', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });
const diff = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '11', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });
assert.strictEqual(diff.status, 409);
assert.strictEqual(diff.status, 409);
- assert.strictEqual(t.events('trade.observation.created').length, 1);
+ assert.strictEqual((await t.events('trade.observation.created')).length, 1);
});
});
await check('observations are immutable in the database', async () => {
await check('observations are immutable in the database', async () => {
- assert.throws(() => t.ctx.store.db.prepare("UPDATE trade_market_observations SET value = '99' WHERE id = ?").run(obsId), /immutable/);
- assert.throws(() => t.ctx.store.db.prepare('DELETE FROM trade_market_observations WHERE id = ?').run(obsId), /immutable/);
+ await assert.rejects(async () => await t.ctx.store.db.prepare("UPDATE trade_market_observations SET value = '99' WHERE id = ?").run(obsId), /immutable/);
+ await assert.rejects(async () => await t.ctx.store.db.prepare('DELETE FROM trade_market_observations WHERE id = ?').run(obsId), /immutable/);
});
});
await check('history keeps every observation, newest first, each with its timestamps', async () => {
await check('history keeps every observation, newest first, each with its timestamps', async () => {
- t.observe(acme, { metric: 'price.close', value: '10.75', source_ref: 'r2', observed_at: '2026-09-22T12:01:00Z', retrieved_at: '2026-09-22T12:02:00Z' });
+ await t.observe(acme, { metric: 'price.close', value: '10.75', source_ref: 'r2', observed_at: '2026-09-22T12:01:00Z', retrieved_at: '2026-09-22T12:02:00Z' });
const h = (await t.get('/api/v1/instruments/ACME/observations?metric=price.close')).json();
const h = (await t.get('/api/v1/instruments/ACME/observations?metric=price.close')).json();
assert.deepStrictEqual(h.observations.map((o) => o.value), ['10.75', '10.50']);
assert.deepStrictEqual(h.observations.map((o) => o.value), ['10.75', '10.50']);
const latest = (await t.get('/i/ACME.json')).json().observations;
const latest = (await t.get('/i/ACME.json')).json().observations;
@@ -13,8 +13,8 @@ const DISCLAIMER = 'Information only — not investment advice; no trading here.
@@ -13,8 +13,8 @@ const DISCLAIMER = 'Information only — not investment advice; no trading here.
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567', exchange: 'NYSE' });
- t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567', exchange: 'NYSE' });
+ await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });
t.sources.setSource('sec-xbrl-filings', { lastSuccessAt: t.iso(t.T0), staleAfterSec: 2700 });
t.sources.setSource('sec-xbrl-filings', { lastSuccessAt: t.iso(t.T0), staleAfterSec: 2700 });
t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm', title: 'ACME CORP', summary: '10-K', published_at: '2026-09-22T10:00:00Z', retrieved_at: t.iso(t.T0) });
t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm', title: 'ACME CORP', summary: '10-K', published_at: '2026-09-22T10:00:00Z', retrieved_at: t.iso(t.T0) });
t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', retrieved_at: t.iso(t.T0) });
t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', retrieved_at: t.iso(t.T0) });
@@ -104,7 +104,7 @@ const DISCLAIMER = 'Information only — not investment advice; no trading here.
@@ -104,7 +104,7 @@ const DISCLAIMER = 'Information only — not investment advice; no trading here.
await check('the historical marketplace branch is an open question in docs, and nothing of it is implemented', async () => {
await check('the historical marketplace branch is an open question in docs, and nothing of it is implemented', async () => {
const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'marketplace-open-question.md'), 'utf8');
const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'marketplace-open-question.md'), 'utf8');
assert.ok(/ADR-025/.test(doc) && /unresolved|open question/i.test(doc));
assert.ok(/ADR-025/.test(doc) && /unresolved|open question/i.test(doc));
- const tables = t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);
+ const tables = (await t.ctx.store.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name);
assert.ok(!tables.some((n) => /order|listing|escrow|wallet|custody|payment|cart/.test(n)), tables.join(','));
assert.ok(!tables.some((n) => /order|listing|escrow|wallet|custody|payment|cart/.test(n)), tables.join(','));
});
});
@@ -9,8 +9,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -9,8 +9,8 @@ const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
- t.instrument({ symbol: 'ZETA', name: 'Zeta Holdings' });
+ await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ await t.instrument({ symbol: 'ZETA', name: 'Zeta Holdings' });
const form = (user, fields) => ({ as: user, form: { _csrf: t.csrf(user), ...fields } });
const form = (user, fields) => ({ as: user, form: { _csrf: t.csrf(user), ...fields } });
let wlId;
let wlId;
@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/boot');
const c = await t.get('/watchlists', form(t.alice, { name: 'Secret plans' }));
const c = await t.get('/watchlists', form(t.alice, { name: 'Secret plans' }));
assert.strictEqual(c.status, 303);
assert.strictEqual(c.status, 303);
assert.ok(/private, no-store/.test(c.headers.get('cache-control')));
assert.ok(/private, no-store/.test(c.headers.get('cache-control')));
- wlId = t.ctx.watchlists.forOwner(t.alice.subject)[0].id;
+ wlId = (await t.ctx.watchlists.forOwner(t.alice.subject))[0].id;
assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, symbol: 'ACME' }))).status, 303);
assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, symbol: 'ACME' }))).status, 303);
assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, q: 'Zeta Holdings' }))).status, 303);
assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, q: 'Zeta Holdings' }))).status, 303);
const page = await t.get('/watchlists', { as: t.alice });
const page = await t.get('/watchlists', { as: t.alice });
@@ -42,7 +42,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -42,7 +42,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
const r2 = await t.get('/watchlists', { as: t.alice, form: { _csrf: t.csrf(t.bob), name: 'Injected' } });
const r2 = await t.get('/watchlists', { as: t.alice, form: { _csrf: t.csrf(t.bob), name: 'Injected' } });
assert.strictEqual(r2.status, 403);
assert.strictEqual(r2.status, 403);
- assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1);
+ assert.strictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).length, 1);
});
});
await check('bob cannot see, change or delete alice\'s watchlist (404, existence not disclosed)', async () => {
await check('bob cannot see, change or delete alice\'s watchlist (404, existence not disclosed)', async () => {
@@ -55,7 +55,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -55,7 +55,7 @@ const { boot, check, done } = require('./helpers/boot');
const del = await t.get('/watchlists/delete', form(t.bob, { watchlist_id: wlId }));
const del = await t.get('/watchlists/delete', form(t.bob, { watchlist_id: wlId }));
assert.strictEqual(del.status, 303);
assert.strictEqual(del.status, 303);
assert.strictEqual(new URL(del.headers.get('location'), 'https://x').searchParams.get('e'), 'No such watchlist');
assert.strictEqual(new URL(del.headers.get('location'), 'https://x').searchParams.get('e'), 'No such watchlist');
- assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1, 'still there');
+ assert.strictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).length, 1, 'still there');
assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');
assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');
});
});
@@ -102,17 +102,17 @@ const { boot, check, done } = require('./helpers/boot');
@@ -102,17 +102,17 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(!all.includes(t.alice.subject));
assert.ok(!all.includes(t.alice.subject));
const robots = (await t.get('/robots.txt')).text;
const robots = (await t.get('/robots.txt')).text;
for (const p of ['/watchlists', '/alerts', '/editor', '/api/']) assert.ok(robots.includes(`Disallow: ${p}`), p);
for (const p of ['/watchlists', '/alerts', '/editor', '/api/']) assert.ok(robots.includes(`Disallow: ${p}`), p);
- const events = JSON.stringify(t.events());
+ const events = JSON.stringify(await t.events());
assert.ok(!events.includes(wlId) && !events.includes('Secret plans') && !events.includes('watchlist'));
assert.ok(!events.includes(wlId) && !events.includes('Secret plans') && !events.includes('watchlist'));
- assert.ok(!t.events().some((e) => e.event_type.includes('watchlist')));
+ assert.ok(!(await t.events()).some((e) => e.event_type.includes('watchlist')));
});
});
await check('removing and deleting work for the owner', async () => {
await check('removing and deleting work for the owner', async () => {
assert.strictEqual((await t.get('/watchlists/item/remove', form(t.alice, { watchlist_id: wlId, symbol: 'ZETA' }))).status, 303);
assert.strictEqual((await t.get('/watchlists/item/remove', form(t.alice, { watchlist_id: wlId, symbol: 'ZETA' }))).status, 303);
- assert.deepStrictEqual(t.ctx.watchlists.items(t.ctx.watchlists.forOwner(t.alice.subject)[0]).map((i) => i.symbol), ['ACME']);
+ assert.deepStrictEqual((await t.ctx.watchlists.items((await t.ctx.watchlists.forOwner(t.alice.subject))[0])).map((i) => i.symbol), ['ACME']);
assert.strictEqual((await t.get('/watchlists/delete', form(t.alice, { watchlist_id: wlId }))).status, 303);
assert.strictEqual((await t.get('/watchlists/delete', form(t.alice, { watchlist_id: wlId }))).status, 303);
- assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 0);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?').get(wlId).n, 0);
+ assert.strictEqual((await t.ctx.watchlists.forOwner(t.alice.subject)).length, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?').get(wlId)).n, 0);
});
});
await t.close();
await t.close();
@@ -12,9 +12,9 @@ const { mapItem } = require('../server/domain/mapping');
@@ -12,9 +12,9 @@ const { mapItem } = require('../server/domain/mapping');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
- const apple = t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'NASDAQ' });
- t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
- t.instrument({ symbol: 'ACMX', name: 'ACME CORPORATION' }); // normalises to the same name as ACME Corp
+ const apple = await t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'NASDAQ' });
+ await t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ await t.instrument({ symbol: 'ACMX', name: 'ACME CORPORATION' }); // normalises to the same name as ACME Corp
const ret = t.iso(t.T0 - 5 * 60e3);
const ret = t.iso(t.T0 - 5 * 60e3);
t.sources.setSource('sec-xbrl-filings', { name: 'SEC EDGAR: latest XBRL filings', lastSuccessAt: ret, staleAfterSec: 2700 });
t.sources.setSource('sec-xbrl-filings', { name: 'SEC EDGAR: latest XBRL filings', lastSuccessAt: ret, staleAfterSec: 2700 });
@@ -54,50 +54,50 @@ const { mapItem } = require('../server/domain/mapping');
@@ -54,50 +54,50 @@ const { mapItem } = require('../server/domain/mapping');
assert.strictEqual(d.retrieved_at, ret);
assert.strictEqual(d.retrieved_at, ret);
assert.strictEqual(d.source.item_id, filing.id);
assert.strictEqual(d.source.item_id, filing.id);
assert.deepStrictEqual(json.observations.map((o) => [o.metric, o.value, o.observed_at]), [['price.close', '231.40', '2026-09-21T20:00:00.000Z']]);
assert.deepStrictEqual(json.observations.map((o) => [o.metric, o.value, o.observed_at]), [['price.close', '231.40', '2026-09-21T20:00:00.000Z']]);
- assert.strictEqual(t.ctx.sync.state().cursor, 4);
+ assert.strictEqual((await t.ctx.sync.state()).cursor, 4);
const src = json.sources.find((s) => s.key === 'sec-xbrl-filings');
const src = json.sources.find((s) => s.key === 'sec-xbrl-filings');
assert.strictEqual(src.name, 'SEC EDGAR: latest XBRL filings');
assert.strictEqual(src.name, 'SEC EDGAR: latest XBRL filings');
assert.strictEqual(src.stale_after_sec, 2700);
assert.strictEqual(src.stale_after_sec, 2700);
});
});
await check('replaying the same Sources pages (cursor reset) creates nothing new', async () => {
await check('replaying the same Sources pages (cursor reset) creates nothing new', async () => {
- const docs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n;
- const obs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;
- const evts = t.events().length;
- t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();
+ const docs = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get()).n;
+ const obs = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n;
+ const evts = (await t.events()).length;
+ await t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();
const r = await t.ctx.sync.run();
const r = await t.ctx.sync.run();
assert.strictEqual(r.ok, true);
assert.strictEqual(r.ok, true);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n, docs);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, obs);
- assert.strictEqual(t.events('trade.observation.created').length, 1);
- assert.strictEqual(t.events().length, evts, 'no new events at all');
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get()).n, docs);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get()).n, obs);
+ assert.strictEqual((await t.events('trade.observation.created')).length, 1);
+ assert.strictEqual((await t.events()).length, evts, 'no new events at all');
});
});
await check('a removed Sources item hides the document (the row stays as the record)', async () => {
await check('a removed Sources item hides the document (the row stays as the record)', async () => {
t.sources.remove(filing.id, { at: t.iso(t.T0), reason: 'takedown request' });
t.sources.remove(filing.id, { at: t.iso(t.T0), reason: 'takedown request' });
await t.ctx.sync.run();
await t.ctx.sync.run();
assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 0);
assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 0);
- const row = t.ctx.store.db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?').get(filing.id);
+ const row = await t.ctx.store.db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?').get(filing.id);
assert.strictEqual(row.removed_reason, 'takedown request');
assert.strictEqual(row.removed_reason, 'takedown request');
const feed = await t.get('/i/AAPL/documents.xml');
const feed = await t.get('/i/AAPL/documents.xml');
assert.ok(!feed.text.includes('10-Q'), 'gone from the feed too');
assert.ok(!feed.text.includes('10-Q'), 'gone from the feed too');
});
});
await check('resolution is deterministic: CIK, then ticker, then exact normalised name; several names → ambiguous', async () => {
await check('resolution is deterministic: CIK, then ticker, then exact normalised name; several names → ambiguous', async () => {
- const r = (q, k) => t.ctx.instruments.resolve(q, k ? { kind: k } : {});
- assert.strictEqual(r('320193').instrument.symbol, 'AAPL');
- assert.strictEqual(r('CIK0000320193').match.kind, 'cik');
- assert.strictEqual(r('aapl').instrument.symbol, 'AAPL');
- assert.strictEqual(r('Apple Inc').instrument.symbol, 'AAPL');
- assert.strictEqual(r('APPLE, INC.').instrument.symbol, 'AAPL');
- const amb = r('Acme Corporation');
+ const r = async (q, k) => await t.ctx.instruments.resolve(q, k ? { kind: k } : {});
+ assert.strictEqual((await r('320193')).instrument.symbol, 'AAPL');
+ assert.strictEqual((await r('CIK0000320193')).match.kind, 'cik');
+ assert.strictEqual((await r('aapl')).instrument.symbol, 'AAPL');
+ assert.strictEqual((await r('Apple Inc')).instrument.symbol, 'AAPL');
+ assert.strictEqual((await r('APPLE, INC.')).instrument.symbol, 'AAPL');
+ const amb = await r('Acme Corporation');
assert.strictEqual(amb.status, 'ambiguous');
assert.strictEqual(amb.status, 'ambiguous');
assert.deepStrictEqual(amb.candidates.map((i) => i.symbol), ['ACME', 'ACMX']);
assert.deepStrictEqual(amb.candidates.map((i) => i.symbol), ['ACME', 'ACMX']);
assert.strictEqual(amb.instrument, null, 'none is chosen');
assert.strictEqual(amb.instrument, null, 'none is chosen');
- assert.strictEqual(r('Appl').status, 'not_found', 'no prefix or fuzzy matching');
- t.ctx.instruments.addAlias(apple, 'ticker', 'APC', t.editor.subject);
- assert.strictEqual(r('APC').match.kind, 'ticker');
- for (let i = 0; i < 3; i++) assert.deepStrictEqual(r('Acme Corporation').candidates.map((x) => x.id), amb.candidates.map((x) => x.id));
+ assert.strictEqual((await r('Appl')).status, 'not_found', 'no prefix or fuzzy matching');
+ await t.ctx.instruments.addAlias(apple, 'ticker', 'APC', t.editor.subject);
+ assert.strictEqual((await r('APC')).match.kind, 'ticker');
+ for (let i = 0; i < 3; i++) assert.deepStrictEqual((await r('Acme Corporation')).candidates.map((x) => x.id), amb.candidates.map((x) => x.id));
const page = await t.get('/i/APC');
const page = await t.get('/i/APC');
assert.strictEqual(page.status, 301);
assert.strictEqual(page.status, 301);
assert.strictEqual(page.headers.get('location'), '/i/AAPL');
assert.strictEqual(page.headers.get('location'), '/i/AAPL');
@@ -110,8 +110,8 @@ const { mapItem } = require('../server/domain/mapping');
@@ -110,8 +110,8 @@ const { mapItem } = require('../server/domain/mapping');
await check('a ticker or CIK names one instrument: collisions are 409', async () => {
await check('a ticker or CIK names one instrument: collisions are 409', async () => {
const ed = t.editor;
const ed = t.editor;
assert.strictEqual((await t.get('/api/v1/instruments', { as: { ...ed, role: 'user' }, json: { symbol: 'APC', name: 'Other' } })).status, 409);
assert.strictEqual((await t.get('/api/v1/instruments', { as: { ...ed, role: 'user' }, json: { symbol: 'APC', name: 'Other' } })).status, 409);
- const acme = t.ctx.instruments.bySymbol('ACME');
- assert.throws(() => t.ctx.instruments.addAlias(acme, 'cik', '320193'), (e) => e.status === 409);
+ const acme = await t.ctx.instruments.bySymbol('ACME');
+ await assert.rejects(async () => await t.ctx.instruments.addAlias(acme, 'cik', '320193'), (e) => e.status === 409);
});
});
await check('the signed webhook wakes the sync once per event id; a bad signature, v1-only or stale v2 is 401', async () => {
await check('the signed webhook wakes the sync once per event id; a bad signature, v1-only or stale v2 is 401', async () => {