openvibers/openvibe.trade Commits

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

OP OpenVibers committed 28 Sept 2026 · e140fcbf32 · 49 files, +1289 −779

- Schema: migrations/0001_initial.sql (openvibe-sdk tools/asyncify gen-migration: the SQLite schema converted, the
  Publishing revisions, authorship and index-hooks tables, the SDK inbox and outbox). The two triggers that keep
  trade_market_observations immutable are PL/pgSQL now (BEFORE UPDATE / BEFORE DELETE raise).
- server/db.js: openDb (DATABASE_URL through PgBouncer, migrations as the owner, PGlite in development) and
  createStore; store.tx is an ambient transaction.
- Every read and write is awaited (asyncify, which now turns xs.map(cb) with an async callback into
  await Promise.all(...), plus hand fixes). The unique-name guards of instruments and watchlists await the write and
  read PostgreSQL's unique_violation (23505, with its table and key) instead of SQLite's message; unawaited, the
  watchlist read raced its insert on a pool. The Sources sync stays synchronous to construct; its cursor row is
  written on first use. The events webhook runs on the PostgreSQL inbox. SQL: COALESCE for IFNULL, lower()/ILIKE,
  ON CONFLICT, a typed IS NULL parameter, named parameters only in the history query.
- Per-actor limits in Valkey; /api/ready reports the store that answered.
- scripts/migrate-to-postgres.js (runSqliteMigration); rehearsed on a copy of production: verified.
- Tests on createTestDb (PGlite; npm run test:pg through PgBouncer). Pins: openvibe-publishing v1.0.0,
  openvibe-contracts v0.76.0, openvibe-sdk v0.20.0. README and STATUS.json follow.

npm test 13/13 on PGlite; npm run test:pg 13/13 through PgBouncer.

Changes

ddiff --git a/.env.example b/.env.example
iindex 591bda4..a777935 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 745d71d..dda20ac 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
contracts-service: 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
ddiff --git a/README.md b/README.md
iindex 3c7e7d2..ca735f4 100644
---- a/README.md
++++ b/README.md
@@@ -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, webhook
signatures 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_OAUTH
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`).
--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):
ddiff --git a/STATUS.json b/STATUS.json
iindex 8f7deec..5bfd56e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/deploy/systemd/openvibe-trade.service b/deploy/systemd/openvibe-trade.service
iindex 677f975..f81f356 100644
---- a/deploy/systemd/openvibe-trade.service
++++ b/deploy/systemd/openvibe-trade.service
@@@ -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=simple
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..cd97db7
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 3d4508c..033feb2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,18 +15,34 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 472a57d..7a23d98 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100644
iindex 0000000..00936db
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex dfabc1d..a5c0d7f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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);
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex 29e50dc..b6245d5 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -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);
ddiff --git a/server/clients/sources.js b/server/clients/sources.js
iindex 9a477a2..a542bb4 100644
---- a/server/clients/sources.js
++++ b/server/clients/sources.js
@@@ -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'),
};
}
ddiff --git a/server/config.js b/server/config.js
iindex 12ff714..f55ea84 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/db.js b/server/db.js
iindex 53d609d..2bc785f 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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)
--);
---- A ticker or CIK names exactly one instrument; a name may be shared (resolution is then ambiguous).
--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);
--
---- Freshness of each source Trade shows data from (not a charter table; a projection of Sources'
---- health plus Trade's own clock).
--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 };
ddiff --git a/server/domain/alerts.js b/server/domain/alerts.js
iindex d770b57..3916ee3 100644
---- a/server/domain/alerts.js
++++ b/server/domain/alerts.js
@@@ -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_id
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 = ?'),
};
-- 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 arrival
const 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);
ddiff --git a/server/domain/context.js b/server/domain/context.js
iindex 6217908..72eb2fb 100644
---- a/server/domain/context.js
++++ b/server/domain/context.js
@@@ -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,
ddiff --git a/server/domain/documents.js b/server/domain/documents.js
iindex 1a95689..a45d499 100644
---- a/server/domain/documents.js
++++ b/server/domain/documents.js
@@@ -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) {
ddiff --git a/server/domain/freshness.js b/server/domain/freshness.js
iindex f1fe97f..4394205 100644
---- a/server/domain/freshness.js
++++ b/server/domain/freshness.js
@@@ -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);
},
};
}
ddiff --git a/server/domain/indexing.js b/server/domain/indexing.js
iindex 23c23ed..2fedd68 100644
---- a/server/domain/indexing.js
++++ b/server/domain/indexing.js
@@@ -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
ORDER 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)),
};
}
ddiff --git a/server/domain/instruments.js b/server/domain/instruments.js
iindex df3ca7a..ee02dd2 100644
---- a/server/domain/instruments.js
++++ b/server/domain/instruments.js
@@@ -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);
}
ddiff --git a/server/domain/observations.js b/server/domain/observations.js
iindex e771f10..bdca17d 100644
---- a/server/domain/observations.js
++++ b/server/domain/observations.js
@@@ -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 = ?) latest
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`),
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;
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
iindex e477014..6c38773 100644
---- a/server/domain/reading.js
++++ b/server/domain/reading.js
@@@ -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),
ddiff --git a/server/domain/sync.js b/server/domain/sync.js
iindex 43c40ec..c0f3a7a 100644
---- a/server/domain/sync.js
++++ b/server/domain/sync.js
@@@ -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, {}) };
},
};
ddiff --git a/server/domain/watchlists.js b/server/domain/watchlists.js
iindex d35addd..413170b 100644
---- a/server/domain/watchlists.js
++++ b/server/domain/watchlists.js
@@@ -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
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 = ?'),
-- 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),
}));
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 4011053..50f5fed 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -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 }),
};
}
ddiff --git a/server/events/webhook.js b/server/events/webhook.js
iindex 52300b9..e0aea05 100644
---- a/server/events/webhook.js
++++ b/server/events/webhook.js
@@@ -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 });
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex c45d6b9..6628a87 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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, lo
limits: { 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}`);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex ab0adb8..498dc34 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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;
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex f93e505..f50682e 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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)),
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 92a9b45..2154ffe 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -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); }
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 368a00b..82f7731 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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),
}),
});
});
ddiff --git a/server/http/private.js b/server/http/private.js
iindex 79f1170..c438f78 100644
---- a/server/http/private.js
++++ b/server/http/private.js
@@@ -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); }
});
ddiff --git a/server/index.js b/server/index.js
iindex 4780d0c..17dfcb1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -7,11 +7,12 @@
*/
const { createApp } = require('./app');
--const { app, ctx } = createApp();
++(async () => {
++const { app, ctx } = await createApp();
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
-- console.log(`[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); });
ddiff --git a/server/observability.js b/server/observability.js
iindex 7a57ae8..cae67c8 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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 } };
},
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 9fc8654..53a0c15 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -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>
ddiff --git a/server/worker.js b/server/worker.js
iindex 6683d5b..2247a8b 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -13,10 +13,10 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console
let 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) {
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex de6d96f..2753d9a 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -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}`);
});
ddiff --git a/test/alerts.test.js b/test/alerts.test.js
iindex 86303fa..7b16ef2 100644
---- a/test/alerts.test.js
++++ b/test/alerts.test.js
@@@ -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 () => {
ddiff --git a/test/context.test.js b/test/context.test.js
iindex 23636d4..71ec95a 100644
---- a/test/context.test.js
++++ b/test/context.test.js
@@@ -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();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 0985360..3e0397b 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -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) {
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
iindex e2acfd2..8113922 100644
---- a/test/delegation.test.js
++++ b/test/delegation.test.js
@@@ -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 () => {
ddiff --git a/test/freshness.test.js b/test/freshness.test.js
iindex 3250ad3..6108a26 100644
---- a/test/freshness.test.js
++++ b/test/freshness.test.js
@@@ -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);
});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 5096212..421567a 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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;
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..2fb2442
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * 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 };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 91dad73..2ede376 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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' });
ddiff --git a/test/observations.test.js b/test/observations.test.js
iindex 984f7f6..052701e 100644
---- a/test/observations.test.js
++++ b/test/observations.test.js
@@@ -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;
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 3f84e18..1b8a45b 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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(','));
});
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
iindex a4bf918..b82d1bf 100644
---- a/test/privacy.test.js
++++ b/test/privacy.test.js
@@@ -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();
ddiff --git a/test/sync.test.js b/test/sync.test.js
iindex 8d5ee23..ca5149c 100644
---- a/test/sync.test.js
++++ b/test/sync.test.js
@@@ -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 Corp
const 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 () => {