openvibers/openvibe.deals Commits
Deals moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.
Changes
@@@ -18,6 +18,22 @@ TRUST_PROXY=2#DEALS_LIMITS_HOUR=3000# SQLite database (production unit: /var/lib/openvibe-deals/deals.db)++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh deals: 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.++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh deals: 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.DEALS_DB_PATH=./data/deals.db# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens@@@ -9,6 +9,9 @@ jobs:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:contracts-service: deals++ # 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@@@ -24,7 +24,7 @@ structured data, the comparison, the feeds and Search.## Owns--The nine charter tables live in Deals' own SQLite (`DEALS_DB_PATH`):++The nine charter tables live in Deals' own PostgreSQL database (`ov_deals` on the host's data role, ADR-035; schema in [migrations/](migrations/)):| Charter table | What it is ||---|---|@@@ -161,7 +161,7 @@ Snapshots are taken on every vote change, merge, unmerge and creation, and by thactive offer of the last 14 days at **one shared `t`**, so the hot list compares numbers computed atthe same moment.--**Abuse controls (server-side, SQLite windows shared by forms and API):**++**Abuse controls (server-side, database windows shared by forms and API):**| Control | Default ||---|---|@@@ -246,9 +246,11 @@ render time, so the cache stays short); signed-in views, forms, API responses, e## Depends on--- **Packages** (pinned release tarballs): `openvibe-publishing` v0.4.0 (seo gate, JSON-LD, feeds,-- sitemaps, index-hooks, discussion, ssr), `openvibe-contracts` v0.53.0, `openvibe-shared` v1.25.0-- (chrome, app icon, footer, legal, release, metrics, ready), `openvibe-sdk` v0.12.0 (outbox, inbox,++- **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 release tarballs): `openvibe-publishing` v1.0.0 (seo gate, JSON-LD, feeds,++ sitemaps, index-hooks, discussion, ssr), `openvibe-contracts` v0.76.0, `openvibe-shared` v1.25.0++ (Frame, app icon, footer, legal, release, metrics, ready), `openvibe-sdk` v0.20.0 (outbox, inbox,webhook signatures v2, service tokens, per-actor limits).- **OpenVibe.Network:** SSO (OAuth client `deals`, registered in production), JWKS,`identity.subject.resolve`.@@@ -370,10 +372,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4840 (set OV_OAUTHProduction deploys with `sudo ovhost deploy deals` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.deals`, install on a lockfile change, restart, wait for `/api/ready`).--The unit is `openvibe-deals.service` on `127.0.0.1:4840`, the env file `/etc/openvibe/deals.env`.++The unit is `openvibe-deals.service` on `127.0.0.1:4840`, the env file `/etc/openvibe/deals.env`. The database is++`ov_deals` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh deals` 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-deals/deals.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 deals --to <sha>`. Nothing blocks a rollback: the schema--code only adds tables and columns.++restart; afterwards `sudo ovhost rollback deals --to <sha>`. Migrations only add tables and columns.First install (done once; kept for a rebuild):@@@ -4,11 +4,16 @@"code": true,"wave": "W18","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": 4840,"domain": "openvibe.deals",-- "contracts": "openvibe-contracts v0.53.0",-- "packages": ["openvibe-publishing v0.4.0", "openvibe-contracts v0.53.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],++ "contracts": "openvibe-contracts v0.76.0",++ "packages": [++ "openvibe-publishing v1.0.0",++ "openvibe-contracts v0.76.0",++ "openvibe-shared v1.25.0",++ "openvibe-sdk v0.20.0"++ ],"capabilities": ["deals.offer.submit","deals.offer.update",@@@ -2,8 +2,8 @@Description=OpenVibe.Deals — community deals with timestamped price observations# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be up first, but Deals# degrades gracefully without them: pages and feeds serve; sign-in, events and imports wait (see /api/ready).--After=network.target--Wants=openvibe-network.service++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service[Service]Type=simplennew file mode 100644@@@ -0,0 +1,284 @@++-- phase: expand++-- OpenVibe.Deals 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 deal_stores (++ id text COLLATE "C" PRIMARY KEY, -- dst_<ULID>++ domain text COLLATE "C" NOT NULL UNIQUE, -- lower-case host without www.++ name text COLLATE "C", -- NULL: shown as the domain (never invented)++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++CREATE TABLE deal_products (++ id text COLLATE "C" PRIMARY KEY, -- dpr_<ULID>++ slug text COLLATE "C" NOT NULL UNIQUE,++ name text COLLATE "C" NOT NULL,++ brand text COLLATE "C",++ category text COLLATE "C",++ description text COLLATE "C",++ created_by text COLLATE "C", -- usr_… or svc:… (imports)++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++CREATE TABLE deal_product_aliases (++ id text COLLATE "C" PRIMARY KEY, -- dpa_<ULID>++ product_id text COLLATE "C" NOT NULL REFERENCES deal_products(id),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('name','gtin','mpn','sku','url')),++ value text COLLATE "C" NOT NULL,++ value_norm text COLLATE "C" NOT NULL,++ source text COLLATE "C" NOT NULL CHECK (source IN ('community','import','moderator')),++ created_by text COLLATE "C",++ created_at bigint NOT NULL,++ UNIQUE (kind, value_norm)++);++CREATE INDEX deal_product_aliases_product ON deal_product_aliases (product_id);++++CREATE TABLE deal_offers (++ id text COLLATE "C" PRIMARY KEY, -- dof_<ULID>++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ slug text COLLATE "C" NOT NULL UNIQUE,++ title text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ url text COLLATE "C" NOT NULL, -- the offer link as normalised (tracking stripped)++ url_norm text COLLATE "C" NOT NULL,++ store_id text COLLATE "C" REFERENCES deal_stores(id),++ product_id text COLLATE "C" REFERENCES deal_products(id),++ category text COLLATE "C",++ origin text COLLATE "C" NOT NULL CHECK (origin IN ('community','import')),++ submitted_by text COLLATE "C", -- usr_… (community) / NULL (import)++ text_origin text COLLATE "C" NOT NULL DEFAULT 'human' CHECK (text_origin IN ('human','imported','ai')),++ review_state text COLLATE "C" NOT NULL DEFAULT 'not_required' CHECK (review_state IN ('not_required','pending','reviewed')),++ reviewed_by text COLLATE "C",++ reviewed_at bigint,++ ai_summary text COLLATE "C", -- AI-assisted text, disclosed, noindex until reviewed++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','expired','disabled')),++ expires_at bigint, -- a STATED expiry (NULL: unknown, never assumed)++ expired_at bigint,++ expired_reason text COLLATE "C",++ disabled_at bigint,++ disabled_reason text COLLATE "C",++ disabled_by text COLLATE "C",++ merged_into text COLLATE "C" REFERENCES deal_offers(id), -- duplicate of … (reversible; nothing is moved)++ merged_at bigint,++ merged_by text COLLATE "C",++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ CHECK (merged_into IS NULL OR merged_into <> id)++);++CREATE INDEX deal_offers_url ON deal_offers (url_norm);++CREATE INDEX deal_offers_listing ON deal_offers (status, merged_into, created_at);++CREATE INDEX deal_offers_merged ON deal_offers (merged_into);++CREATE INDEX deal_offers_product ON deal_offers (product_id);++CREATE INDEX deal_offers_store ON deal_offers (store_id);++++CREATE TABLE deal_offer_sources (++ id text COLLATE "C" PRIMARY KEY, -- dos_<ULID>++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('submission','community','sources_item')),++ ref_service text COLLATE "C" NOT NULL, -- 'deals' | 'sources'++ ref_type text COLLATE "C" NOT NULL, -- 'submission' | 'observer' | 'item'++ ref_id text COLLATE "C" NOT NULL, -- offer id | usr_… | itm_…++ ref_part text COLLATE "C" NOT NULL DEFAULT '', -- which offer inside one Sources item++ ref_revision bigint, -- the Sources item revision last imported++ source_key text COLLATE "C", -- the Sources registry key (display)++ url text COLLATE "C",++ label text COLLATE "C",++ license_note text COLLATE "C",++ submitted_by text COLLATE "C",++ retrieved_at bigint, -- Sources provenance.retrieved_at++ removed_at bigint,++ removed_reason text COLLATE "C",++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ UNIQUE (kind, ref_service, ref_id, ref_part)++);++CREATE INDEX deal_offer_sources_offer ON deal_offer_sources (offer_id);++++CREATE TABLE deal_price_observations (++ id text COLLATE "C" PRIMARY KEY, -- dpo_<ULID>++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id),++ source_id text COLLATE "C" NOT NULL REFERENCES deal_offer_sources(id),++ observed_at bigint NOT NULL, -- when the source saw it (not when we stored it)++ recorded_at bigint NOT NULL,++ price text COLLATE "C", -- decimal exactly as stated; NULL = not stated++ price_num double precision,++ currency text COLLATE "C", -- ISO 4217; NULL = not stated++ shipping text COLLATE "C", -- decimal as stated (same currency); NULL = not stated++ shipping_num double precision,++ shipping_note text COLLATE "C",++ condition text COLLATE "C" CHECK (condition IS NULL OR condition IN ('new','used','refurbished','damaged')),++ availability text COLLATE "C" CHECK (availability IS NULL OR availability IN ('in_stock','out_of_stock','preorder','discontinued','limited','sold_out','online_only','in_store_only')),++ origin text COLLATE "C" NOT NULL CHECK (origin IN ('community','import','import_refresh')),++ observed_by text COLLATE "C", -- usr_… for people; NULL for imports++ source_revision bigint,++ note text COLLATE "C",++ CHECK ((price IS NULL) = (price_num IS NULL)),++ CHECK ((shipping IS NULL) = (shipping_num IS NULL))++);++CREATE INDEX deal_price_observations_offer ON deal_price_observations (offer_id, observed_at);++++CREATE TABLE deal_votes (++ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id),++ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)++ subject text COLLATE "C" NOT NULL, -- usr_…++ value bigint NOT NULL CHECK (value IN (-1, 0, 1)), -- 0 = removed (kept for history)++ weight double precision NOT NULL, -- the weight when cast (new accounts count less)++ ip_hash text COLLATE "C",++ via text COLLATE "C" NOT NULL DEFAULT 'user' CHECK (via IN ('user','service')),++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (offer_id, subject)++);++CREATE INDEX deal_votes_subject ON deal_votes (subject, updated_at);++CREATE INDEX deal_votes_ip ON deal_votes (ip_hash, offer_id);++++CREATE TABLE deal_hotness_snapshots (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id), -- the canonical (root) offer++ computed_at bigint NOT NULL,++ formula text COLLATE "C" NOT NULL,++ up_count bigint NOT NULL,++ down_count bigint NOT NULL,++ up_weight double precision NOT NULL,++ down_weight double precision NOT NULL,++ score double precision NOT NULL,++ hot double precision NOT NULL,++ first_seen_at bigint NOT NULL,++ reason text COLLATE "C" NOT NULL++);++CREATE INDEX deal_hotness_offer ON deal_hotness_snapshots (offer_id, id);++++CREATE TABLE deal_watches (++ id text COLLATE "C" PRIMARY KEY, -- dwt_<ULID>++ subject text COLLATE "C" NOT NULL, -- usr_…++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('keyword','product','price_below','search')),++ query text COLLATE "C",++ product_id text COLLATE "C" REFERENCES deal_products(id),++ max_price text COLLATE "C",++ max_price_num double precision,++ currency text COLLATE "C",++ notify bigint NOT NULL DEFAULT 1, -- 0: a saved search (never notifies)++ label text COLLATE "C",++ created_at bigint NOT NULL,++ deleted_at bigint,++ CHECK (kind <> 'search' OR notify = 0),++ CHECK (kind <> 'price_below' OR (max_price_num IS NOT NULL AND currency IS NOT NULL))++);++CREATE INDEX deal_watches_subject ON deal_watches (subject, deleted_at);++CREATE INDEX deal_watches_active ON deal_watches (notify, deleted_at, kind);++++CREATE TABLE deal_flags (++ id text COLLATE "C" PRIMARY KEY, -- dfl_<ULID>++ offer_id text COLLATE "C" REFERENCES deal_offers(id),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('expired','price_wrong','spam','duplicate','other','vote_ring')),++ origin text COLLATE "C" NOT NULL CHECK (origin IN ('user','system')),++ reporter text COLLATE "C", -- usr_… (NULL for system flags)++ reason text COLLATE "C",++ details text COLLATE "C", -- JSON++ dedupe_key text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved','dismissed')),++ resolved_by text COLLATE "C",++ resolved_at bigint,++ resolution text COLLATE "C",++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE UNIQUE INDEX deal_flags_open ON deal_flags (dedupe_key) WHERE status = 'open';++CREATE INDEX deal_flags_status ON deal_flags (status, created_at);++++CREATE TABLE watch_notifications (++ watch_id text COLLATE "C" NOT NULL REFERENCES deal_watches(id),++ observation_id text COLLATE "C" NOT NULL REFERENCES deal_price_observations(id),++ offer_id text COLLATE "C" NOT NULL, -- the canonical offer when it matched++ price_num double precision,++ currency text COLLATE "C",++ event_id text COLLATE "C",++ created_at bigint NOT NULL,++ PRIMARY KEY (watch_id, observation_id)++);++CREATE INDEX watch_notifications_offer ON watch_notifications (watch_id, offer_id);++++CREATE TABLE moderation_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ action text COLLATE "C" NOT NULL, -- merge | unmerge | expire | disable | enable | review | flag.resolve | flag.dismiss++ offer_id text COLLATE "C",++ target_id text COLLATE "C",++ actor text COLLATE "C" NOT NULL, -- usr_… | svc:…++ reason text COLLATE "C",++ before text COLLATE "C", -- JSON++ after text COLLATE "C", -- JSON++ at bigint NOT NULL++);++CREATE INDEX moderation_log_offer ON moderation_log (offer_id, id);++++CREATE TABLE rate_events (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ kind text COLLATE "C" NOT NULL,++ key text COLLATE "C" NOT NULL,++ at bigint NOT NULL++);++CREATE INDEX rate_events_key ON rate_events (kind, key, at);++++CREATE TABLE import_state (++ key text COLLATE "C" PRIMARY KEY,++ value text COLLATE "C",++ updated_at bigint NOT NULL++);++++CREATE TABLE subject_projections (++ subject text COLLATE "C" PRIMARY KEY,++ username text COLLATE "C",++ display_name text COLLATE "C",++ avatar_url text COLLATE "C",++ first_seen_at bigint, -- when Deals first saw this person sign in++ refreshed_at bigint NOT NULL++);++++-- openvibe-publishing/discussion (prefix deal_offer)++CREATE TABLE IF NOT EXISTS deal_offer_discussion_refs (++ entity_id text COLLATE "C" PRIMARY KEY,++ thread_id text NOT NULL,++ ref jsonb NOT NULL,++ resolved_at bigint NOT NULL++);++++-- openvibe-publishing/index-hooks (prefix deal)++CREATE TABLE IF NOT EXISTS deal_index_revisions (++ owner text COLLATE "C" NOT NULL,++ type text COLLATE "C" NOT NULL,++ id text COLLATE "C" NOT NULL,++ revision integer NOT NULL,++ hash text NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (owner, type, id)++);++++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled++CREATE TABLE IF NOT EXISTS idempotency_receipts (++ consumer text NOT NULL,++ event_id text NOT NULL,++ processed_at bigint NOT NULL,++ PRIMARY KEY (consumer, event_id)++);++++-- openvibe-sdk/events PostgreSQL outbox++CREATE TABLE IF NOT EXISTS event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;@@@ -15,18 +15,34 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.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.53.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",++ "version": "0.76.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1031,15 +1120,15 @@}},"node_modules/openvibe-publishing": {-- "version": "0.4.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",++ "version": "1.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==","license": "MIT","engines": {"node": ">=20"},"peerDependencies": {-- "better-sqlite3": ">=11",++ "openvibe-sdk": ">=0.15.0","openvibe-shared": ">=1.5.0"},"peerDependenciesMeta": {@@@ -1049,12 +1138,32 @@}},"node_modules/openvibe-sdk": {-- "version": "0.12.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",++ "version": "0.20.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",++ "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==","license": "MIT","engines": {"node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }}},"node_modules/openvibe-shared": {@@@ -1081,6 +1190,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1204,6 +1441,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1419,6 +1677,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1546,6 +1819,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -2,14 +2,20 @@"name": "openvibe-deals","version": "0.1.0","private": true,-- "description": "OpenVibe.Deals — community-submitted and source-imported deals with timestamped price observations, server-authoritative votes and hotness, reversible duplicate merges, watches and saved searches, server-rendered and useful without JavaScript.",++ "description": "OpenVibe.Deals \u2014 community-submitted and source-imported deals with timestamped price observations, server-authoritative votes and hotness, reversible duplicate merges, watches and saved searches, server-rendered and useful without JavaScript.","main": "server/index.js","scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/run.js"++ "test": "node test/run.js",++ "test:pg": "DEALS_TEST_STORE=pg node test/run.js"},-- "keywords": ["openvibe", "deals", "prices", "publishing"],++ "keywords": [++ "openvibe",++ "deals",++ "prices",++ "publishing"++ ],"author": "OpenVibers","license": "AGPL-3.0-only","repository": "github:OpenVibers/OpenVibe.Deals",@@@ -20,13 +26,16 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",++ "pg": "^8.23.0"},"devDependencies": {-- "jsonwebtoken": "^9.0.2"++ "jsonwebtoken": "^9.0.2",++ "@electric-sql/pglite": "^0.5.8"},"engines": {"node": ">=22.0.0"nnew file mode 100644@@@ -0,0 +1,26 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Deals's SQLite database (DEALS_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: 'deals', 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 };@@@ -49,11 +49,12 @@ const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;/** opts: config, dbPath, now (clock), fetchImpl, auth, log, limitsNow (the per-actor limiter's clock, tests) */--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 });const outbox = createDealsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });const people = createPeople({ store, config, fetchImpl });@@@ -87,7 +88,10 @@ function createApp(opts = {}) {app.locals.ctx = ctx;// Per-actor limits (http/actor-limits.js) for the API and the page forms, counted once each router// resolved req.viewer; the per-address limits below and the per-person abuse controls stay.-- ctx.actorLimits = 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.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });app.use(contracts.http.middleware());app.use(helmet({@@@ -118,7 +122,7 @@ function createApp(opts = {}) {app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-deals', version: VERSION }));// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.release.mount(app, { registry: metrics.registry });-- const readiness = createDealsReadiness({ store, auth, outbox, importer, worker, limits, release: release.release });++ const readiness = createDealsReadiness({ store, auth, outbox, importer, worker, limits, release: release.release, valkey: ctx.valkey });app.get('/api/ready', readiness.handler);// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────@@@ -66,7 +66,7 @@ function createViewerResolver({ auth, config, people }) {const claims = await auth.verify(token);if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;-- if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }++ if (subject && people) { try { await people.rememberClaims(subject, claims); } catch { /* display cache only */ } }// Staff = the contracts staff map's staff.content.moderate (ADR-022), or one of this product's own moderators.const staff = staffMap.can(claims, 'staff.content.moderate') || Boolean(subject && moderators.has(subject));return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };@@@ -80,7 +80,7 @@ function createViewerResolver({ auth, config, people }) {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);@@@ -50,11 +50,11 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {publicUrl: config.community.publicUrl,/** The stored thread id (no network call), or null. */-- knownThread(offer) { const k = store.discussion.get(offer.id); return k ? k.threadId : null; },++ async knownThread(offer) { const k = await store.discussion.get(offer.id); return k ? k.threadId : null; },/** The stored thread id, or resolve it once through Community. */async threadFor(offer, label, ctx) {-- const known = store.discussion.get(offer.id);++ const known = await store.discussion.get(offer.id);if (known) return known.threadId;if (!discussion) return null;const out = await store.discussion.threadFor(offer.id, refFor(offer, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });@@@ -64,13 +64,13 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {/** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */async readThread(threadId, { after, ctx } = {}) {const qs = after ? `?after=${encodeURIComponent(after)}` : '';-- return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });++ return await call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });},/** Comment as the signed-in member. */async comment(threadId, subject, { message, parentId } = {}, ctx) {if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }-- return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {++ return await call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,});},@@@ -79,7 +79,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {* Best effort: hide an offer's thread when moderators disable it, show it again when enabled. Needs community.comment.moderate.*/async setThreadVisibility(offer, visibility, ctx) {-- const known = store.discussion.get(offer.id);++ const known = await store.discussion.get(offer.id);if (!known || !modTokens) return false;try {await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });@@@ -23,6 +23,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {}) : null;const q = {get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),++ getMany: db.prepare('SELECT * FROM subject_projections WHERE subject = ANY(?)'),byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,@@@ -32,9 +33,9 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {};const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);-- function remember(subject, p) {++ async function remember(subject, p) {if (!ids.isSubjectId('user', subject) || !p) return;-- q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());++ await q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());}async function resolve(subjects) {@@@ -48,7 +49,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {if (res.status === 401) tokens.invalidate && tokens.invalidate();const data = await res.json().catch(() => null);if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);-- for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);++ for (const s of subjects) if (data.results[s]) await remember(s, data.results[s]);}const shape = (row, subject) => (row@@@ -58,15 +59,17 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {return {enabled,/** From a verified sign-in: the member's own claims are a fresh projection. */-- rememberClaims(subject, claims) { remember(subject, claims); q.seen.run(store.now(), subject); },++ async rememberClaims(subject, claims) { await remember(subject, claims); await q.seen.run(store.now(), subject); },/** When Deals first saw this person signed in (abuse controls: account age evidence), or null. */-- firstSeen(subject) { const r = q.firstSeen.get(subject); return r ? r.first_seen_at : null; },++ async firstSeen(subject) { const r = await q.firstSeen.get(subject); return r ? r.first_seen_at : null; },/** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */async many(subjects) {const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];-- const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });++ const read = async () => new Map((list.length ? await q.getMany.all(list) : []).map((r) => [r.subject, r]));++ let rows = await read();++ const missing = list.filter((s) => { const r = rows.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });if (missing.length && tokens) {let timer;await Promise.race([@@@ -74,19 +77,20 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),]);clearTimeout(timer);++ rows = await read();}-- return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));++ return new Map(list.map((s) => [s, shape(rows.get(s) || null, s)]));},async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },/** The subject a username belongs to, from people Deals has seen. */-- byUsername(username) {-- const row = q.byUsername.get(String(username || '').toLowerCase());++ async byUsername(username) {++ const row = await q.byUsername.get(String(username || '').toLowerCase());return row ? shape(row, row.subject) : null;},-- cached: (subject) => shape(q.get.get(subject), subject),++ cached: async (subject) => shape(await q.get.get(subject), subject),};}@@@ -39,8 +39,8 @@ function createSources({ config, fetchImpl = globalThis.fetch }) {return {enabled,-- items: ({ after = 0, limit = 100 } = {}, ctx) => call(`/api/v1/items?category=deals&after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}&include_removed=1`, ctx),-- item: (id, ctx) => call(`/api/v1/items/${encodeURIComponent(id)}`, ctx),++ items: async ({ after = 0, limit = 100 } = {}, ctx) => await call(`/api/v1/items?category=deals&after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}&include_removed=1`, ctx),++ item: async (id, ctx) => await call(`/api/v1/items/${encodeURIComponent(id)}`, ctx),};}@@@ -37,6 +37,10 @@ function load(env = process.env) {hour: Math.max(1, int(env.DEALS_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:deals:' },++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.dbPath: env.DEALS_DB_PATH || './data/deals.db',// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.@@@ -1,8 +1,8 @@'use strict';/**-- * Deals' own SQLite database: created on boot, idempotently. Nothing here is shared with another-- * service.++ * Deals' 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):*@@@ -32,266 +32,53 @@*/const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++const { createDb } = require('openvibe-sdk/db');const { createDiscussionRefs } = require('openvibe-publishing/discussion');const { createIndexSequencer } = require('openvibe-publishing/index-hooks');--const SCHEMA = `--CREATE TABLE IF NOT EXISTS deal_stores (-- id TEXT PRIMARY KEY, -- dst_<ULID>-- domain TEXT NOT NULL UNIQUE, -- lower-case host without www.-- name TEXT, -- NULL: shown as the domain (never invented)-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);----CREATE TABLE IF NOT EXISTS deal_products (-- id TEXT PRIMARY KEY, -- dpr_<ULID>-- slug TEXT NOT NULL UNIQUE,-- name TEXT NOT NULL,-- brand TEXT,-- category TEXT,-- description TEXT,-- created_by TEXT, -- usr_… or svc:… (imports)-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);----CREATE TABLE IF NOT EXISTS deal_product_aliases (-- id TEXT PRIMARY KEY, -- dpa_<ULID>-- product_id TEXT NOT NULL REFERENCES deal_products(id),-- kind TEXT NOT NULL CHECK (kind IN ('name','gtin','mpn','sku','url')),-- value TEXT NOT NULL,-- value_norm TEXT NOT NULL,-- source TEXT NOT NULL CHECK (source IN ('community','import','moderator')),-- created_by TEXT,-- created_at INTEGER NOT NULL,-- UNIQUE (kind, value_norm)--);--CREATE INDEX IF NOT EXISTS deal_product_aliases_product ON deal_product_aliases (product_id);----CREATE TABLE IF NOT EXISTS deal_offers (-- id TEXT PRIMARY KEY, -- dof_<ULID>-- slug TEXT NOT NULL UNIQUE,-- title TEXT NOT NULL,-- description TEXT,-- url TEXT NOT NULL, -- the offer link as normalised (tracking stripped)-- url_norm TEXT NOT NULL,-- store_id TEXT REFERENCES deal_stores(id),-- product_id TEXT REFERENCES deal_products(id),-- category TEXT,-- origin TEXT NOT NULL CHECK (origin IN ('community','import')),-- submitted_by TEXT, -- usr_… (community) / NULL (import)-- text_origin TEXT NOT NULL DEFAULT 'human' CHECK (text_origin IN ('human','imported','ai')),-- review_state TEXT NOT NULL DEFAULT 'not_required' CHECK (review_state IN ('not_required','pending','reviewed')),-- reviewed_by TEXT,-- reviewed_at INTEGER,-- ai_summary TEXT, -- AI-assisted text, disclosed, noindex until reviewed-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','expired','disabled')),-- expires_at INTEGER, -- a STATED expiry (NULL: unknown, never assumed)-- expired_at INTEGER,-- expired_reason TEXT,-- disabled_at INTEGER,-- disabled_reason TEXT,-- disabled_by TEXT,-- merged_into TEXT REFERENCES deal_offers(id), -- duplicate of … (reversible; nothing is moved)-- merged_at INTEGER,-- merged_by TEXT,-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- CHECK (merged_into IS NULL OR merged_into <> id)--);--CREATE INDEX IF NOT EXISTS deal_offers_url ON deal_offers (url_norm);--CREATE INDEX IF NOT EXISTS deal_offers_listing ON deal_offers (status, merged_into, created_at);--CREATE INDEX IF NOT EXISTS deal_offers_merged ON deal_offers (merged_into);--CREATE INDEX IF NOT EXISTS deal_offers_product ON deal_offers (product_id);--CREATE INDEX IF NOT EXISTS deal_offers_store ON deal_offers (store_id);----CREATE TABLE IF NOT EXISTS deal_offer_sources (-- id TEXT PRIMARY KEY, -- dos_<ULID>-- offer_id TEXT NOT NULL REFERENCES deal_offers(id),-- kind TEXT NOT NULL CHECK (kind IN ('submission','community','sources_item')),-- ref_service TEXT NOT NULL, -- 'deals' | 'sources'-- ref_type TEXT NOT NULL, -- 'submission' | 'observer' | 'item'-- ref_id TEXT NOT NULL, -- offer id | usr_… | itm_…-- ref_part TEXT NOT NULL DEFAULT '', -- which offer inside one Sources item-- ref_revision INTEGER, -- the Sources item revision last imported-- source_key TEXT, -- the Sources registry key (display)-- url TEXT,-- label TEXT,-- license_note TEXT,-- submitted_by TEXT,-- retrieved_at INTEGER, -- Sources provenance.retrieved_at-- removed_at INTEGER,-- removed_reason TEXT,-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- UNIQUE (kind, ref_service, ref_id, ref_part)--);--CREATE INDEX IF NOT EXISTS deal_offer_sources_offer ON deal_offer_sources (offer_id);----CREATE TABLE IF NOT EXISTS deal_price_observations (-- id TEXT PRIMARY KEY, -- dpo_<ULID>-- offer_id TEXT NOT NULL REFERENCES deal_offers(id),-- source_id TEXT NOT NULL REFERENCES deal_offer_sources(id),-- observed_at INTEGER NOT NULL, -- when the source saw it (not when we stored it)-- recorded_at INTEGER NOT NULL,-- price TEXT, -- decimal exactly as stated; NULL = not stated-- price_num REAL,-- currency TEXT, -- ISO 4217; NULL = not stated-- shipping TEXT, -- decimal as stated (same currency); NULL = not stated-- shipping_num REAL,-- shipping_note TEXT,-- condition TEXT CHECK (condition IS NULL OR condition IN ('new','used','refurbished','damaged')),-- availability TEXT CHECK (availability IS NULL OR availability IN ('in_stock','out_of_stock','preorder','discontinued','limited','sold_out','online_only','in_store_only')),-- origin TEXT NOT NULL CHECK (origin IN ('community','import','import_refresh')),-- observed_by TEXT, -- usr_… for people; NULL for imports-- source_revision INTEGER,-- note TEXT,-- CHECK ((price IS NULL) = (price_num IS NULL)),-- CHECK ((shipping IS NULL) = (shipping_num IS NULL))--);--CREATE INDEX IF NOT EXISTS deal_price_observations_offer ON deal_price_observations (offer_id, observed_at);----CREATE TABLE IF NOT EXISTS deal_votes (-- offer_id TEXT NOT NULL REFERENCES deal_offers(id),-- subject TEXT NOT NULL, -- usr_…-- value INTEGER NOT NULL CHECK (value IN (-1, 0, 1)), -- 0 = removed (kept for history)-- weight REAL NOT NULL, -- the weight when cast (new accounts count less)-- ip_hash TEXT,-- via TEXT NOT NULL DEFAULT 'user' CHECK (via IN ('user','service')),-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- PRIMARY KEY (offer_id, subject)--);--CREATE INDEX IF NOT EXISTS deal_votes_subject ON deal_votes (subject, updated_at);--CREATE INDEX IF NOT EXISTS deal_votes_ip ON deal_votes (ip_hash, offer_id);----CREATE TABLE IF NOT EXISTS deal_hotness_snapshots (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- offer_id TEXT NOT NULL REFERENCES deal_offers(id), -- the canonical (root) offer-- computed_at INTEGER NOT NULL,-- formula TEXT NOT NULL,-- up_count INTEGER NOT NULL,-- down_count INTEGER NOT NULL,-- up_weight REAL NOT NULL,-- down_weight REAL NOT NULL,-- score REAL NOT NULL,-- hot REAL NOT NULL,-- first_seen_at INTEGER NOT NULL,-- reason TEXT NOT NULL--);--CREATE INDEX IF NOT EXISTS deal_hotness_offer ON deal_hotness_snapshots (offer_id, id);----CREATE TABLE IF NOT EXISTS deal_watches (-- id TEXT PRIMARY KEY, -- dwt_<ULID>-- subject TEXT NOT NULL, -- usr_…-- kind TEXT NOT NULL CHECK (kind IN ('keyword','product','price_below','search')),-- query TEXT,-- product_id TEXT REFERENCES deal_products(id),-- max_price TEXT,-- max_price_num REAL,-- currency TEXT,-- notify INTEGER NOT NULL DEFAULT 1, -- 0: a saved search (never notifies)-- label TEXT,-- created_at INTEGER NOT NULL,-- deleted_at INTEGER,-- CHECK (kind <> 'search' OR notify = 0),-- CHECK (kind <> 'price_below' OR (max_price_num IS NOT NULL AND currency IS NOT NULL))--);--CREATE INDEX IF NOT EXISTS deal_watches_subject ON deal_watches (subject, deleted_at);--CREATE INDEX IF NOT EXISTS deal_watches_active ON deal_watches (notify, deleted_at, kind);----CREATE TABLE IF NOT EXISTS deal_flags (-- id TEXT PRIMARY KEY, -- dfl_<ULID>-- offer_id TEXT REFERENCES deal_offers(id),-- kind TEXT NOT NULL CHECK (kind IN ('expired','price_wrong','spam','duplicate','other','vote_ring')),-- origin TEXT NOT NULL CHECK (origin IN ('user','system')),-- reporter TEXT, -- usr_… (NULL for system flags)-- reason TEXT,-- details TEXT, -- JSON-- dedupe_key TEXT NOT NULL,-- status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved','dismissed')),-- resolved_by TEXT,-- resolved_at INTEGER,-- resolution TEXT,-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);--CREATE UNIQUE INDEX IF NOT EXISTS deal_flags_open ON deal_flags (dedupe_key) WHERE status = 'open';--CREATE INDEX IF NOT EXISTS deal_flags_status ON deal_flags (status, created_at);----CREATE TABLE IF NOT EXISTS watch_notifications (-- watch_id TEXT NOT NULL REFERENCES deal_watches(id),-- observation_id TEXT NOT NULL REFERENCES deal_price_observations(id),-- offer_id TEXT NOT NULL, -- the canonical offer when it matched-- price_num REAL,-- currency TEXT,-- event_id TEXT,-- created_at INTEGER NOT NULL,-- PRIMARY KEY (watch_id, observation_id)--);--CREATE INDEX IF NOT EXISTS watch_notifications_offer ON watch_notifications (watch_id, offer_id);----CREATE TABLE IF NOT EXISTS moderation_log (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- action TEXT NOT NULL, -- merge | unmerge | expire | disable | enable | review | flag.resolve | flag.dismiss-- offer_id TEXT,-- target_id TEXT,-- actor TEXT NOT NULL, -- usr_… | svc:…-- reason TEXT,-- before TEXT, -- JSON-- after TEXT, -- JSON-- at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS moderation_log_offer ON moderation_log (offer_id, id);----CREATE TABLE IF NOT EXISTS rate_events (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- kind TEXT NOT NULL,-- key TEXT NOT NULL,-- at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS rate_events_key ON rate_events (kind, key, at);----CREATE TABLE IF NOT EXISTS import_state (-- key TEXT PRIMARY KEY,-- value TEXT,-- updated_at INTEGER NOT NULL--);----CREATE TABLE IF NOT EXISTS subject_projections (-- subject TEXT PRIMARY KEY,-- username TEXT,-- display_name TEXT,-- avatar_url TEXT,-- first_seen_at INTEGER, -- when Deals first saw this person sign in-- refreshed_at INTEGER NOT NULL--);--`;++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');const CHARTER_TABLES = ['deal_products', 'deal_product_aliases', 'deal_offers', 'deal_offer_sources', 'deal_votes','deal_hotness_snapshots', 'deal_watches', 'deal_price_observations', 'deal_flags'];/**-- * Open (or create) the database. opts.now — injectable clock (epoch ms) shared by everything, so-- * tests and replays are deterministic.++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database++ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.++ */++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 deals)');++ log.warn(`[Deals] 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: 'deals', 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: 'deals-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'deals', registry, log });++}++++/**++ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms) shared by everything, 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,discussion: createDiscussionRefs(db, { prefix: 'deal_offer', now }),sequencer: createIndexSequencer(db, { prefix: 'deal', now }),-- tx: (fn) => db.transaction(fn)(),++ tx: async (fn) => await db.tx(() => fn()),close: () => db.close(),};}--module.exports = { openStore, CHARTER_TABLES };++/** openDb + createStore. */++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, CHARTER_TABLES, MIGRATIONS };@@@ -32,21 +32,21 @@ function createCatalog({ store }) {insertProduct: db.prepare(`INSERT INTO deal_products (id, slug, name, brand, category, description, created_by, created_at, updated_at)VALUES (@id, @slug, @name, @brand, @category, @description, @created_by, @now, @now)`),alias: db.prepare('SELECT * FROM deal_product_aliases WHERE kind = ? AND value_norm = ?'),-- insertAlias: db.prepare(`INSERT OR IGNORE INTO deal_product_aliases (id, product_id, kind, value, value_norm, source, created_by, created_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),++ insertAlias: db.prepare(`INSERT INTO deal_product_aliases (id, product_id, kind, value, value_norm, source, created_by, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),aliases: db.prepare('SELECT kind, value, source, created_at FROM deal_product_aliases WHERE product_id = ? ORDER BY kind, value'),};-- function ensureStore(domain, name = null) {++ async function ensureStore(domain, name = null) {if (!domain) return null;-- const existing = q.storeByDomain.get(domain);++ const existing = await q.storeByDomain.get(domain);if (existing) {-- if (name && !existing.name) q.nameStore.run(String(name).slice(0, 120), store.now(), existing.id);-- return q.storeByDomain.get(domain);++ if (name && !existing.name) await q.nameStore.run(String(name).slice(0, 120), store.now(), existing.id);++ return await q.storeByDomain.get(domain);}const id = newId('dst');-- q.insertStore.run(id, domain, name ? String(name).slice(0, 120) : null, store.now(), store.now());-- return q.storeById.get(id);++ await q.insertStore.run(id, domain, name ? String(name).slice(0, 120) : null, store.now(), store.now());++ return await q.storeById.get(id);}function storeLabel(s) { return s ? (s.name || s.domain) : null; }@@@ -57,31 +57,31 @@ function createCatalog({ store }) {* reported in `conflicts`), never re-pointed silently.* input: { name, brand, category, description, gtin, mpn, sku, url }, source, actor*/-- function resolve(input, { source = 'community', actor = null, create = true } = {}) {++ async function resolve(input, { source = 'community', actor = null, create = true } = {}) {const given = ALIAS_KINDS.map((kind) => ({ kind, value: input[kind], norm: normAlias(kind, input[kind]) })).filter((a) => a.norm);if (input.gtin && !normAlias('gtin', input.gtin)) throw new ApiError(422, 'request.invalid', 'gtin must be 8 to 14 digits');let product = null;for (const a of given) {-- const hit = q.alias.get(a.kind, a.norm);-- if (hit) { product = q.productById.get(hit.product_id); break; }++ const hit = await q.alias.get(a.kind, a.norm);++ if (hit) { product = await q.productById.get(hit.product_id); break; }}let created = false;if (!product) {if (!create) return { product: null, created: false, conflicts: [] };const name = text(input.name, { field: 'product name', min: 2, max: 200, required: true });const id = newId('dpr');-- q.insertProduct.run({++ await q.insertProduct.run({id, slug: slugify(name, id), name, brand: text(input.brand, { max: 120 }), category: text(input.category, { max: 60 }),description: longText(input.description, 2000), created_by: actor, now: store.now(),});-- product = q.productById.get(id);++ product = await q.productById.get(id);created = true;}const conflicts = [];for (const a of given) {-- const hit = q.alias.get(a.kind, a.norm);++ const hit = await q.alias.get(a.kind, a.norm);if (hit && hit.product_id !== product.id) { conflicts.push({ kind: a.kind, value: a.value, product_id: hit.product_id }); continue; }-- if (!hit) q.insertAlias.run(newId('dpa'), product.id, a.kind, String(a.value).trim().slice(0, 300), a.norm, source, actor, store.now());++ if (!hit) await q.insertAlias.run(newId('dpa'), product.id, a.kind, String(a.value).trim().slice(0, 300), a.norm, source, actor, store.now());}return { product, created, conflicts };}@@@ -89,11 +89,11 @@ function createCatalog({ store }) {return {ensureStore,storeLabel,-- store: (id) => (id ? q.storeById.get(id) : null),-- storeByDomain: (d) => q.storeByDomain.get(String(d || '').toLowerCase()),-- product: (id) => (id ? q.productById.get(id) : null),-- productBySlug: (slug) => q.productBySlug.get(String(slug || '')),-- aliases: (productId) => q.aliases.all(productId),++ store: async (id) => (id ? await q.storeById.get(id) : null),++ storeByDomain: async (d) => await q.storeByDomain.get(String(d || '').toLowerCase()),++ product: async (id) => (id ? await q.productById.get(id) : null),++ productBySlug: async (slug) => await q.productBySlug.get(String(slug || '')),++ aliases: async (productId) => await q.aliases.all(productId),resolve,normAlias,};@@@ -20,73 +20,73 @@ function createFlags({ config, store, reads, limits, access, publication, logActVALUES (@id, @offer_id, @kind, @origin, @reporter, @reason, @details, @dedupe_key, 'open', @now, @now)`),touch: db.prepare('UPDATE deal_flags SET details = ?, reason = COALESCE(?, reason), updated_at = ? WHERE id = ?'),list: db.prepare('SELECT * FROM deal_flags WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'),-- forOffer: db.prepare('SELECT * FROM deal_flags WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY created_at DESC'),++ forOffer: db.prepare('SELECT * FROM deal_flags WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY created_at DESC'),resolve: db.prepare('UPDATE deal_flags SET status = ?, resolved_by = ?, resolved_at = ?, resolution = ?, updated_at = ? WHERE id = ?'),};/** A person's report. */-- function report(viewer, idOrSlug, input = {}, { ip = null } = {}) {++ async function report(viewer, idOrSlug, input = {}, { ip = null } = {}) {const subject = viewer && viewer.subject;if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a deal');-- const root = reads.root(reads.mustFind(idOrSlug));++ const root = await reads.root(await reads.mustFind(idOrSlug));const kind = String(input.kind || '');if (!USER_KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${USER_KINDS.join(', ')}`);const reason = text(input.reason, { field: 'reason', max: 500 });let details = {};if (kind === 'duplicate') {-- const other = input.duplicate_of ? reads.find(String(input.duplicate_of).trim().replace(/^.*\/d\//, '').replace(/(\.json)?([?#].*)?$/, '')) : null;++ const other = input.duplicate_of ? await reads.find(String(input.duplicate_of).trim().replace(/^.*\/d\//, '').replace(/(\.json)?([?#].*)?$/, '')) : null;if (!other) throw new ApiError(422, 'request.invalid', 'duplicate_of must name the other deal (id or slug)');-- details = { duplicate_of: reads.root(other).id };++ details = { duplicate_of: (await reads.root(other)).id };}-- return store.tx(() => {++ return await store.tx(async () => {const key = `user:${subject}:${root.id}`;-- const open = q.openByKey.get(key);++ const open = await q.openByKey.get(key);if (open) {-- q.touch.run(JSON.stringify({ ...JSON.parse(open.details || '{}'), ...details, kind }), reason, store.now(), open.id);-- return { flag: q.get.get(open.id), created: false };++ await q.touch.run(JSON.stringify({ ...JSON.parse(open.details || '{}'), ...details, kind }), reason, store.now(), open.id);++ return { flag: await q.get.get(open.id), created: false };}-- limits.check('flag', subject, config.abuse.flagPerDay, 24 * 3600 * 1000, 'flag.rate_limited');++ await limits.check('flag', subject, config.abuse.flagPerDay, 24 * 3600 * 1000, 'flag.rate_limited');const id = newId('dfl');-- q.insert.run({ id, offer_id: root.id, kind, origin: 'user', reporter: subject, reason, details: JSON.stringify(details), dedupe_key: key, now: store.now() });-- limits.hit('flag', subject);-- if (ip) limits.hit('flag_ip', ip);-- return { flag: q.get.get(id), created: true };++ await q.insert.run({ id, offer_id: root.id, kind, origin: 'user', reporter: subject, reason, details: JSON.stringify(details), dedupe_key: key, now: store.now() });++ await limits.hit('flag', subject);++ if (ip) await limits.hit('flag_ip', ip);++ return { flag: await q.get.get(id), created: true };});}/** A system flag (inside the caller's transaction): opened once per key, details refreshed. */-- function raise({ kind, offerId, key, reason, details }) {-- const open = q.openByKey.get(key);-- if (open) { q.touch.run(JSON.stringify(details || {}), reason || null, store.now(), open.id); return { flag: q.get.get(open.id), created: false }; }++ async function raise({ kind, offerId, key, reason, details }) {++ const open = await q.openByKey.get(key);++ if (open) { await q.touch.run(JSON.stringify(details || {}), reason || null, store.now(), open.id); return { flag: await q.get.get(open.id), created: false }; }const id = newId('dfl');-- q.insert.run({ id, offer_id: offerId, kind, origin: 'system', reporter: null, reason: reason || null, details: JSON.stringify(details || {}), dedupe_key: key, now: store.now() });-- return { flag: q.get.get(id), created: true };++ await q.insert.run({ id, offer_id: offerId, kind, origin: 'system', reporter: null, reason: reason || null, details: JSON.stringify(details || {}), dedupe_key: key, now: store.now() });++ return { flag: await q.get.get(id), created: true };}-- function resolve(viewer, flagId, { status = 'resolved', resolution } = {}, { traceparent } = {}) {++ async function resolve(viewer, flagId, { status = 'resolved', resolution } = {}, { traceparent } = {}) {if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');-- const f = q.get.get(String(flagId || ''));++ const f = await q.get.get(String(flagId || ''));if (!f) throw new ApiError(404, 'flag.not_found', 'No such flag');if (f.status !== 'open') return { flag: f, changed: false };const actor = viewer.subject || viewer.service;-- return store.tx(() => {-- q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);++ return await store.tx(async () => {++ await q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });// A moderator's decision on a report goes to Network's moderation audit log (ADR-022).if (outbox) {const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;-- outbox.moderationAction({++ await outbox.moderationAction({action: `flag.${status}`, target: { type: 'flag', id: f.id, owner_subject: f.reporter || null }, actorSubject: person,reason: text(resolution, { max: 300 }) || null, details: { offer_id: f.offer_id, kind: f.kind, origin: f.origin },}, { traceparent });}-- return { flag: q.get.get(f.id), changed: true };++ return { flag: await q.get.get(f.id), changed: true };});}-- function dto(f) {-- const offer = f.offer_id ? reads.get(f.offer_id) : null;++ async function dto(f) {++ const offer = f.offer_id ? await reads.get(f.offer_id) : null;return {id: f.id, kind: f.kind, origin: f.origin, reporter: f.reporter, reason: f.reason, status: f.status,details: JSON.parse(f.details || '{}'), created_at: iso(f.created_at), updated_at: iso(f.updated_at),@@@ -97,8 +97,8 @@ function createFlags({ config, store, reads, limits, access, publication, logActreturn {report, raise, resolve, dto, USER_KINDS,-- list: ({ status = 'open', limit = 100, offset = 0 } = {}) => q.list.all(status, limit, offset),-- forOffer: (ids) => q.forOffer.all(JSON.stringify(ids)),++ list: async ({ status = 'open', limit = 100, offset = 0 } = {}) => await q.list.all(status, limit, offset),++ forOffer: async (ids) => await q.forOffer.all(JSON.stringify(ids)),};}@@@ -40,24 +40,24 @@ function createHotness({ store, reads }) {const { db } = store;const insert = db.prepare(`INSERT INTO deal_hotness_snapshots (offer_id, computed_at, formula, up_count, down_count, up_weight, down_weight, score, hot, first_seen_at, reason)VALUES (@offer_id, @computed_at, @formula, @up_count, @down_count, @up_weight, @down_weight, @score, @hot, @first_seen_at, @reason)`);-- const firstSeen = db.prepare('SELECT MIN(created_at) AS t FROM deal_offers WHERE id IN (SELECT value FROM json_each(?))');++ const firstSeen = db.prepare('SELECT MIN(created_at) AS t FROM deal_offers WHERE id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb))');/** Inputs for a canonical offer at time t (from the database, never from a request). */-- function inputs(rootId, t) {-- const ids = reads.groupIds(rootId);-- const tally = reads.tally(ids);-- return { ids, tally, firstSeenAt: firstSeen.get(JSON.stringify(ids)).t, t };++ async function inputs(rootId, t) {++ const ids = await reads.groupIds(rootId);++ const tally = await reads.tally(ids);++ return { ids, tally, firstSeenAt: (await firstSeen.get(JSON.stringify(ids))).t, t };}/** Compute and store a snapshot for a canonical offer. Inside the caller's transaction. */-- function snapshot(rootId, reason, t = store.now()) {-- const { tally, firstSeenAt } = inputs(rootId, t);++ async function snapshot(rootId, reason, t = store.now()) {++ const { tally, firstSeenAt } = await inputs(rootId, t);const r = formula({ upWeight: tally.upWeight, downWeight: tally.downWeight, firstSeenAt, t });const row = {offer_id: rootId, computed_at: t, formula: r.formula, up_count: tally.up, down_count: tally.down,up_weight: tally.upWeight, down_weight: tally.downWeight, score: r.score, hot: r.hot, first_seen_at: firstSeenAt, reason,};-- insert.run(row);++ await insert.run(row);return row;}@@@ -67,11 +67,11 @@ function createHotness({ store, reads }) {}/** Worker: snapshot every active canonical offer created in the window, all at the same t. */-- function tick({ windowDays = 14, t = store.now() } = {}) {-- const ids = db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?`).all(t - windowDays * 24 * HOUR).map((r) => r.id);-- store.tx(() => { for (const id of ids) snapshot(id, 'tick', t); });++ async function tick({ windowDays = 14, t = store.now() } = {}) {++ const ids = (await db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);++ await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });// Keep the latest snapshot per offer and everything from the last 7 days.-- db.prepare(`DELETE FROM deal_hotness_snapshots WHERE computed_at < ? AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)`).run(t - 7 * 24 * HOUR);++ await db.prepare(`DELETE FROM deal_hotness_snapshots WHERE computed_at < ? AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)`).run(t - 7 * 24 * HOUR);return ids.length;}@@@ -47,14 +47,14 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcJOIN deal_offers f ON f.id = s.offer_idJOIN deal_price_observations o ON o.source_id = s.idWHERE s.kind = 'sources_item' AND s.removed_at IS NULL AND f.status = 'active'-- GROUP BY s.ref_id HAVING last < ? ORDER BY last LIMIT ?`),++ GROUP BY s.ref_id HAVING MAX(o.observed_at) < ? ORDER BY last LIMIT ?`),};let lastError = null;let lastRunAt = null;let running = null;let kickTimer = null;-- const cursor = () => Number((q.getState.get(CURSOR_KEY) || {}).value || 0);++ const cursor = async () => Number((await q.getState.get(CURSOR_KEY) || {}).value || 0);function amount(v) { try { return parseAmount(v, 'price'); } catch { return null; } }function currencyOf(v) { return typeof v === 'string' && /^[A-Z]{3}$/.test(v) ? v : null; }@@@ -102,30 +102,30 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcreturn [];}-- function removeItem(item) {++ async function removeItem(item) {const reason = (item.removed && item.removed.reason) || 'removed at the source';-- const rows = q.itemRows.all(item.id);-- for (const s of rows) q.removeSource.run(store.now(), reason, store.now(), s.id);++ const rows = await q.itemRows.all(item.id);++ for (const s of rows) await q.removeSource.run(store.now(), reason, store.now(), s.id);const touched = [...new Set(rows.map((s) => s.offer_id))];for (const id of touched) {-- const offer = reads.get(id);-- if (offer.origin === 'import' && offer.status !== 'disabled' && q.liveSources.get(id).n === 0) {++ const offer = await reads.get(id);++ if (offer.origin === 'import' && offer.status !== 'disabled' && (await q.liveSources.get(id)).n === 0) {const now = store.now();-- db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = 'svc:deals', updated_at = ? WHERE id = ?")++ await db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = 'svc:deals', updated_at = ? WHERE id = ?").run(now, `source item removed: ${reason}`, now, id);-- offers.logAction('disable', { offerId: id, actor: 'svc:deals', reason: `source item removed: ${reason}`, before: { status: offer.status }, after: { status: 'disabled' } });++ await offers.logAction('disable', { offerId: id, actor: 'svc:deals', reason: `source item removed: ${reason}`, before: { status: offer.status }, after: { status: 'disabled' } });}-- const after = reads.get(id);-- indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed: ['sources'], source_removed: item.id } });-- indexing.reindex(after);++ const after = await reads.get(id);++ await indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed: ['sources'], source_removed: item.id } });++ await indexing.reindex(after);}return touched.length ? 'removed' : 'removed:unknown';}/** One item, inside a transaction. Returns what happened (for logs and tests). */-- function importItem(item) {++ async function importItem(item) {if (!item || item.category !== 'deals') return 'skipped:category';-- if (item.removed) return removeItem(item);++ if (item.removed) return await removeItem(item);const retrievedAt = Date.parse(item.provenance && item.provenance.retrieved_at);if (!Number.isFinite(retrievedAt)) return 'skipped:no_observation_time';const list = candidates(item);@@@ -137,42 +137,42 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcsource_key: item.source_key, url: item.canonical_url, label: item.title,license_note: item.provenance.license_note || null, retrieved_at: retrievedAt,};-- const existing = q.sourceRow.get(item.id, c.part);++ const existing = await q.sourceRow.get(item.id, c.part);if (existing) {const advanced = item.revision > (existing.ref_revision || 0) || retrievedAt > (existing.retrieved_at || 0);if (!advanced || existing.removed_at) { outcomes.push('unchanged'); continue; }const origin = item.revision > (existing.ref_revision || 0) ? 'import' : 'import_refresh';-- q.bumpSource.run(Math.max(item.revision, existing.ref_revision || 0), Math.max(retrievedAt, existing.retrieved_at || 0), item.title, store.now(), existing.id);-- const obs = offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });-- const offer = reads.get(existing.offer_id);++ await q.bumpSource.run(Math.max(item.revision, existing.ref_revision || 0), Math.max(retrievedAt, existing.retrieved_at || 0), item.title, store.now(), existing.id);++ const obs = await offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });++ const offer = await reads.get(existing.offer_id);const changed = ['observation'];if (origin === 'import' && c.expires_at && offer.status === 'active' && offer.origin === 'import' && c.expires_at !== offer.expires_at) {-- db.prepare('UPDATE deal_offers SET expires_at = ?, updated_at = ? WHERE id = ?').run(c.expires_at, store.now(), offer.id);++ await db.prepare('UPDATE deal_offers SET expires_at = ?, updated_at = ? WHERE id = ?').run(c.expires_at, store.now(), offer.id);changed.push('expires_at');}// Anything else the observation moved on the offer (price, product) is named too, so consumers never miss a field.-- const after = reads.get(offer.id);++ const after = await reads.get(offer.id);for (const k of ['product_id', 'title', 'status']) if (after && offer && after[k] !== offer[k] && !changed.includes(k)) changed.push(k);-- indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed, observation_id: obs.id } });-- indexing.reindex(reads.get(offer.id));++ await indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed, observation_id: obs.id } });++ await indexing.reindex(await reads.get(offer.id));outcomes.push(origin === 'import' ? 'updated' : 'refreshed');continue;}let productId = null;if (c.product) {-- try { productId = catalog.resolve(c.product, { source: 'import', actor: 'svc:deals' }).product.id; } catch { productId = null; }++ try { productId = (await catalog.resolve(c.product, { source: 'import', actor: 'svc:deals' })).product.id; } catch { productId = null; }}-- const dup = offers.findByUrl(c.url);++ const dup = await offers.findByUrl(c.url);if (dup) {-- const s = offers.ensureSource(dup.id, src);-- const obs = offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });-- if (productId && !dup.product_id) db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);-- indexing.emitOffer('deals.offer.updated', reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });-- indexing.reindex(reads.get(dup.id));++ const s = await offers.ensureSource(dup.id, src);++ const obs = await offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });++ if (productId && !dup.product_id) await db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);++ await indexing.emitOffer('deals.offer.updated', await reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });++ await indexing.reindex(await reads.get(dup.id));outcomes.push('attached');continue;}-- offers.createImported({++ await offers.createImported({url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId,store_name: c.store_name, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,}, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });@@@ -181,7 +181,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcreturn outcomes.join(',');}-- const importOne = db.transaction((item) => importItem(item));++ const importOne = async (item) => await db.tx(async () => await importItem(item));/** Pull the change feed from the cursor. Never throws; the cursor only moves past applied items. */async function pull() {@@@ -191,18 +191,18 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcconst summary = { pages: 0, items: 0, outcomes: {} };try {for (let p = 0; p < config.sources.maxPages; p++) {-- const after = cursor();++ const after = await cursor();const page = await sources.items({ after, limit: config.sources.pageSize });summary.pages++;-- store.tx(() => {++ await store.tx(async () => {for (const item of page.items || []) {let out;// Each item in its own savepoint: one unreadable item is reported, not a stuck cursor.-- try { out = importOne(item); } catch (err) { out = 'failed'; log.warn(`[Deals] import of ${item && item.id} failed: ${err.message}`); }++ try { out = await importOne(item); } catch (err) { out = 'failed'; log.warn(`[Deals] import of ${item && item.id} failed: ${err.message}`); }summary.items++;for (const o of out.split(',')) summary.outcomes[o] = (summary.outcomes[o] || 0) + 1;}-- if (Number.isFinite(page.next_after) && page.next_after > after) q.setState.run(CURSOR_KEY, String(page.next_after), store.now());++ if (Number.isFinite(page.next_after) && page.next_after > after) await q.setState.run(CURSOR_KEY, String(page.next_after), store.now());});if (!page.more) break;}@@@ -226,13 +226,13 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc*/async function refresh() {if (!sources.enabled) return { skipped: 'import off' };-- const due = q.refreshDue.all(store.now() - config.freshnessMs / 2, config.sources.refreshBatch);++ const due = await q.refreshDue.all(store.now() - config.freshnessMs / 2, config.sources.refreshBatch);const out = { checked: 0, outcomes: {} };for (const { ref_id: id } of due) {try {const data = await sources.item(id);out.checked++;-- const o = store.tx(() => importItem(data.item));++ const o = await store.tx(async () => await importItem(data.item));for (const x of o.split(',')) out.outcomes[x] = (out.outcomes[x] || 0) + 1;} catch (err) {if (err.status === 404) continue;@@@ -251,7 +251,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourcreturn {pull, refresh, kick, importItem, cursor,-- status: () => ({ enabled: sources.enabled, cursor: cursor(), last_error: lastError, last_run_at: iso(lastRunAt) }),++ status: async () => ({ enabled: sources.enabled, cursor: await cursor(), last_error: lastError, last_run_at: iso(lastRunAt) }),};}@@@ -23,17 +23,17 @@ function actorRef(actor) {}function createIndexing({ store, publication, outbox, catalog }) {-- const { sequencer } = store;++ const { sequencer, db } = store;function latestPayload(v) {return v.latest ? { price: v.latest.price, currency: v.latest.currency, availability: v.latest.availability, observed_at: iso(v.latest.observed_at) } : null;}/** deals.offer.* for the canonical offer. */-- function emitOffer(type, offer, { actor = null, extra = {}, traceparent } = {}) {-- const v = publication.offerView(offer);++ async function emitOffer(type, offer, { actor = null, extra = {}, traceparent } = {}) {++ const v = await publication.offerView(offer);const r = v.root;-- return outbox.emit({++ return await outbox.emit({event_type: type,version: 1,source: 'deals',@@@ -50,9 +50,9 @@ function createIndexing({ store, publication, outbox, catalog }) {}, { traceparent });}-- function emitIfNew(doc, before) {++ async function emitIfNew(doc, before) {if (before != null && doc.revision === before) return null;-- return outbox.emit(hooks.indexEvent({ document: doc, now: store.now() }));++ return await outbox.emit(hooks.indexEvent({ document: doc, now: store.now() }));}function offerDocument(v) {@@@ -81,21 +81,21 @@ function createIndexing({ store, publication, outbox, catalog }) {});}-- function indexOffer(offer) {++ async function indexOffer(offer) {if (!offer) return null;if (offer.merged_into) {-- const before = sequencer.current('deals', 'offer', offer.id);-- return emitIfNew(sequencer.stamp(hooks.tombstone({ owner: 'deals', type: 'offer', id: offer.id, revision: 0 })), before);++ const before = await sequencer.current('deals', 'offer', offer.id);++ return await emitIfNew(await sequencer.stamp(db, hooks.tombstone({ owner: 'deals', type: 'offer', id: offer.id, revision: 0 })), before);}-- const v = publication.offerView(offer);-- const before = sequencer.current('deals', 'offer', v.root.id);-- return emitIfNew(sequencer.stamp(offerDocument(v)), before);++ const v = await publication.offerView(offer);++ const before = await sequencer.current('deals', 'offer', v.root.id);++ return await emitIfNew(await sequencer.stamp(db, offerDocument(v)), before);}-- function indexProduct(product) {++ async function indexProduct(product) {if (!product) return null;-- const pv = publication.productView(product);-- const before = sequencer.current('deals', 'product', product.id);++ const pv = await publication.productView(product);++ const before = await sequencer.current('deals', 'product', product.id);const lines = pv.offers.filter((v) => v.root.status === 'active').slice(0, 20).map((v) => `${v.root.title}${v.latest && v.latest.price != null ? ` — ${v.latest.price} ${v.latest.currency || ''} as of ${iso(v.latest.observed_at)}` : ''}`);const doc = hooks.buildIndexDocument({owner: 'deals', type: 'product', id: product.id, revision: 0, state: 'published', visibility: 'public',@@@ -108,13 +108,13 @@ function createIndexing({ store, publication, outbox, catalog }) {publishedAt: product.created_at,updatedAt: pv.freshestObservedAt || product.updated_at,});-- return emitIfNew(sequencer.stamp(doc), before);++ return await emitIfNew(await sequencer.stamp(db, doc), before);}/** After any change to an offer: its index document and its product's. */-- function reindex(offer) {-- indexOffer(offer);-- if (offer && offer.product_id) indexProduct(catalog.product(offer.product_id));++ async function reindex(offer) {++ await indexOffer(offer);++ if (offer && offer.product_id) await indexProduct(await catalog.product(offer.product_id));}return { emitOffer, indexOffer, indexProduct, reindex, actorRef, offerDocument };@@@ -20,24 +20,24 @@ function createLimits({ config, store }) {const insert = db.prepare('INSERT INTO rate_events (kind, key, at) VALUES (?, ?, ?)');const prune = db.prepare('DELETE FROM rate_events WHERE at < ?');-- function check(kind, key, max, windowMs, code = 'rate.limited') {++ async function check(kind, key, max, windowMs, code = 'rate.limited') {if (!key || !(max > 0)) return;const since = store.now() - windowMs;-- const r = count.get(kind, key, since);++ const r = await count.get(kind, key, since);if (r.n >= max) {const retry = Math.max(1, Math.ceil((r.oldest + windowMs - store.now()) / 1000));throw new ApiError(429, code, `Too many ${kind.replace(/_/g, ' ')} actions; try again in ${retry} s`, { retry_after: retry });}}-- function hit(kind, key) { if (key) insert.run(kind, key, store.now()); }++ async function hit(kind, key) { if (key) await insert.run(kind, key, store.now()); }function ipHash(ip) {if (!ip) return null;return crypto.createHmac('sha256', config.ipHashSecret || fallbackKey).update(`deals-ip:${ip}`).digest('base64url').slice(0, 22);}-- return { check, hit, ipHash, prune: (olderThanMs = 3 * 24 * 3600 * 1000) => prune.run(store.now() - olderThanMs).changes, stableIpHash: Boolean(config.ipHashSecret) };++ return { check, hit, ipHash, prune: async (olderThanMs = 3 * 24 * 3600 * 1000) => (await prune.run(store.now() - olderThanMs)).changes, stableIpHash: Boolean(config.ipHashSecret) };}module.exports = { createLimits };@@@ -15,8 +15,8 @@ function createListings({ store }) {const LATEST = 'h.id = (SELECT MAX(id) FROM deal_hotness_snapshots WHERE offer_id = o.id)';const q = {hot: db.prepare(`SELECT o.*, h.hot AS hot FROM deal_offers o LEFT JOIN deal_hotness_snapshots h ON ${LATEST}-- WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY COALESCE(h.hot, 0) DESC, o.created_at DESC, o.rowid DESC LIMIT ? OFFSET ?`),-- newest: db.prepare(`SELECT o.* FROM deal_offers o WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY o.created_at DESC, o.rowid DESC LIMIT ? OFFSET ?`),++ WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY COALESCE(h.hot, 0) DESC, o.created_at DESC, o.seq DESC LIMIT ? OFFSET ?`),++ newest: db.prepare(`SELECT o.* FROM deal_offers o WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY o.created_at DESC, o.seq DESC LIMIT ? OFFSET ?`),countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),@@@ -30,28 +30,28 @@ function createListings({ store }) {pendingReview: db.prepare(`SELECT * FROM deal_offers WHERE review_state = 'pending' AND merged_into IS NULL AND status <> 'disabled' ORDER BY created_at DESC LIMIT ?`),};-- function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {++ async function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {const words = [...new Set(tokens(query))].slice(0, 6);if (!words.length) return { total: 0, rows: [] };const where = words.map((_, i) => `(' ' || lower(o.title) || ' ' || lower(COALESCE(o.description, '')) || ' ') LIKE @w${i}`).join(' AND ');const params = Object.fromEntries(words.map((w, i) => [`w${i}`, `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`]));const status = includeExpired ? "o.status <> 'disabled'" : "o.status = 'active'";const base = `FROM deal_offers o WHERE o.merged_into IS NULL AND ${status} AND ${where.replace(/LIKE (@w\d+)/g, "LIKE $1 ESCAPE '\\'")}`;-- const total = db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params).n;-- const rows = db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });++ const total = (await db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params)).n;++ const rows = await db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });return { total, rows, words };}return {-- hot: (limit, offset) => q.hot.all(limit, offset),-- newest: (limit, offset) => q.newest.all(limit, offset),-- countActive: () => q.countActive.get().n,-- byStore: (storeId, limit, offset) => q.byStore.all(storeId, limit, offset),-- countStore: (storeId) => q.countStore.get(storeId).n,-- indexable: () => q.indexable.all(),-- products: () => q.products.all(),-- possibleDuplicates: (limit = 50) => q.duplicates.all(limit),-- pendingReview: (limit = 50) => q.pendingReview.all(limit),++ hot: async (limit, offset) => await q.hot.all(limit, offset),++ newest: async (limit, offset) => await q.newest.all(limit, offset),++ countActive: async () => (await q.countActive.get()).n,++ byStore: async (storeId, limit, offset) => await q.byStore.all(storeId, limit, offset),++ countStore: async (storeId) => (await q.countStore.get(storeId)).n,++ indexable: async () => await q.indexable.all(),++ products: async () => await q.products.all(),++ possibleDuplicates: async (limit = 50) => await q.duplicates.all(limit),++ pendingReview: async (limit = 50) => await q.pendingReview.all(limit),search,};}@@@ -47,7 +47,7 @@ function createOffers(deps) {@shipping_num, @shipping_note, @condition, @availability, @origin, @observed_by, @source_revision, @note)`),obs: db.prepare('SELECT * FROM deal_price_observations WHERE id = ?'),log: db.prepare('INSERT INTO moderation_log (action, offer_id, target_id, actor, reason, before, after, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'),-- logFor: db.prepare(`SELECT * FROM moderation_log WHERE offer_id IN (SELECT value FROM json_each(?)) OR target_id IN (SELECT value FROM json_each(?)) ORDER BY id`),++ logFor: db.prepare(`SELECT * FROM moderation_log WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) OR target_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY id`),dueExpiry: db.prepare("SELECT * FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND expires_at IS NOT NULL AND expires_at <= ?"),};@@@ -75,32 +75,32 @@ function createOffers(deps) {* to Network's moderation audit log (ADR-022), in the caller's transaction. The submitter (or a* service acting for them) acting on their own offer, and AI text deliveries, are not.*/-- function moderated(viewer, root, action, { reason = null, details = {}, traceparent } = {}) {++ async function moderated(viewer, root, action, { reason = null, details = {}, traceparent } = {}) {if (!outbox || !viewer || (viewer.kind === 'service' && viewer.origin === 'ai')) return null;if (viewer.subject && viewer.subject === root.submitted_by) return null;const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;-- return outbox.moderationAction({ action, target: { type: 'offer', id: root.id, owner_subject: root.submitted_by || null }, actorSubject: person, reason, details }, { traceparent });++ return await outbox.moderationAction({ action, target: { type: 'offer', id: root.id, owner_subject: root.submitted_by || null }, actorSubject: person, reason, details }, { traceparent });}-- function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {-- q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());++ async function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {++ await q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());}-- function ensureSource(offerId, s) {++ async function ensureSource(offerId, s) {const part = s.ref_part || '';-- const existing = q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);++ const existing = await q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);if (existing) return existing;const row = {id: newId('dos'), offer_id: offerId, kind: s.kind, ref_service: s.ref_service, ref_type: s.ref_type, ref_id: s.ref_id, ref_part: part,ref_revision: s.ref_revision == null ? null : s.ref_revision, source_key: s.source_key || null, url: s.url || null, label: s.label || null,license_note: s.license_note || null, submitted_by: s.submitted_by || null, retrieved_at: s.retrieved_at == null ? null : s.retrieved_at, now: store.now(),};-- q.insertSource.run(row);-- return q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);++ await q.insertSource.run(row);++ return await q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);}/** Insert one observation and run the watch matcher. Inside the caller's transaction. */-- function recordObservation(offerId, sourceId, fields, { origin, observedBy = null, observedAt, sourceRevision = null }) {++ async function recordObservation(offerId, sourceId, fields, { origin, observedBy = null, observedAt, sourceRevision = null }) {const now = store.now();if (!Number.isFinite(observedAt)) throw new ApiError(422, 'request.invalid', 'an observation needs the time it was observed');if (observedAt > now + FUTURE_SKEW_MS) throw new ApiError(422, 'request.invalid', 'observed_at is in the future');@@@ -110,32 +110,32 @@ function createOffers(deps) {shipping_note: fields.shipping_note || null, condition: fields.condition || null, availability: fields.availability || null,origin, observed_by: observedBy, source_revision: sourceRevision, note: fields.note || null,};-- q.insertObs.run(row);-- const obs = q.obs.get(row.id);-- watches.onObservation(obs);++ await q.insertObs.run(row);++ const obs = await q.obs.get(row.id);++ await watches.onObservation(obs);return obs;}-- function insertOffer(f) {++ async function insertOffer(f) {const id = newId('dof');-- q.insertOffer.run({++ await q.insertOffer.run({id, slug: slugify(f.title, id), title: f.title, description: f.description || null, url: f.url, url_norm: urlKey(f.url),store_id: f.store_id || null, product_id: f.product_id || null, category: f.category || null, origin: f.origin,submitted_by: f.submitted_by || null, text_origin: f.text_origin || 'human', review_state: f.review_state || 'not_required',ai_summary: f.ai_summary || null, expires_at: f.expires_at == null ? null : f.expires_at, now: store.now(),});-- return reads.get(id);++ return await reads.get(id);}-- const findByUrl = (url) => { const hit = q.byUrl.get(urlKey(url)); return hit ? reads.root(hit) : null; };++ const findByUrl = async (url) => { const hit = await q.byUrl.get(urlKey(url)); return hit ? await reads.root(hit) : null; };-- function mustRoot(idOrSlug) { return reads.root(reads.mustFind(idOrSlug)); }++ async function mustRoot(idOrSlug) { return await reads.root(await reads.mustFind(idOrSlug)); }-- function tallies(root) { return reads.tally(reads.groupIds(root.id)); }++ async function tallies(root) { return await reads.tally(await reads.groupIds(root.id)); }// ── submission ──────────────────────────────────────────-- function submit(viewer, input = {}, { ip = null, traceparent } = {}) {++ async function submit(viewer, input = {}, { ip = null, traceparent } = {}) {const subject = viewer && viewer.subject;if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to submit a deal');const url = normalizeUrl(input.url);@@@ -146,55 +146,55 @@ function createOffers(deps) {const expiresAt = parseInstant(input.expires_at, 'expires_at');if (expiresAt != null && expiresAt <= store.now()) throw new ApiError(422, 'request.invalid', 'expires_at is already in the past');const ai = viewer.kind === 'service' && viewer.origin === 'ai';-- return store.tx(() => {-- limits.check('submit', subject, config.abuse.submitPerDay, 24 * 3600 * 1000, 'submit.rate_limited');-- const dup = findByUrl(url);++ return await store.tx(async () => {++ await limits.check('submit', subject, config.abuse.submitPerDay, 24 * 3600 * 1000, 'submit.rate_limited');++ const dup = await findByUrl(url);if (dup) throw new ApiError(409, 'offer.duplicate', 'This link has already been posted', { existing: { id: dup.id, slug: dup.slug, url: publication.abs(publication.offerPath(dup)) } });-- const st = catalog.ensureStore(domainOf(url), text(input.store_name, { max: 120 }));++ const st = await catalog.ensureStore(domainOf(url), text(input.store_name, { max: 120 }));let product = null;const p = input.product && typeof input.product === 'object' ? input.product : null;if (input.product_slug) {-- product = catalog.productBySlug(input.product_slug);++ product = await catalog.productBySlug(input.product_slug);if (!product) throw new ApiError(404, 'product.not_found', 'No such product');} else if (p && (p.name || p.gtin || p.mpn || p.sku)) {-- product = catalog.resolve(p, { source: 'community', actor: subject }).product;++ product = (await catalog.resolve(p, { source: 'community', actor: subject })).product;}-- const offer = insertOffer({++ const offer = await insertOffer({title, description, url, store_id: st ? st.id : null, product_id: product ? product.id : null,category: text(input.category, { max: 60 }), origin: 'community', submitted_by: subject,text_origin: ai ? 'ai' : 'human', review_state: ai ? 'pending' : 'not_required', expires_at: expiresAt,});-- const src = ensureSource(offer.id, { kind: 'submission', ref_service: 'deals', ref_type: 'submission', ref_id: offer.id, url, submitted_by: subject });-- limits.hit('submit', subject);-- if (ip) limits.hit('submit_ip', ip);-- hotness.snapshot(offer.id, 'create');-- recordObservation(offer.id, src.id, obs, { origin: 'community', observedBy: subject, observedAt: store.now() });-- indexing.emitOffer('deals.offer.created', offer, { actor: subject, traceparent });-- indexing.reindex(reads.get(offer.id));-- return { offer: reads.get(offer.id), created: true };++ const src = await ensureSource(offer.id, { kind: 'submission', ref_service: 'deals', ref_type: 'submission', ref_id: offer.id, url, submitted_by: subject });++ await limits.hit('submit', subject);++ if (ip) await limits.hit('submit_ip', ip);++ await hotness.snapshot(offer.id, 'create');++ await recordObservation(offer.id, src.id, obs, { origin: 'community', observedBy: subject, observedAt: store.now() });++ await indexing.emitOffer('deals.offer.created', offer, { actor: subject, traceparent });++ await indexing.reindex(await reads.get(offer.id));++ return { offer: await reads.get(offer.id), created: true };});}/** Created by the Sources importer (no person); text is third-party and waits for review. */-- function createImported(f, source, obsFields, { observedAt, sourceRevision }) {-- const st = catalog.ensureStore(domainOf(f.url), f.store_name || null);-- const offer = insertOffer({++ async function createImported(f, source, obsFields, { observedAt, sourceRevision }) {++ const st = await catalog.ensureStore(domainOf(f.url), f.store_name || null);++ const offer = await insertOffer({title: f.title, description: f.description, url: f.url, store_id: st ? st.id : null, product_id: f.product_id || null,category: f.category || null, origin: 'import', submitted_by: null, text_origin: 'imported', review_state: 'pending',expires_at: f.expires_at == null ? null : f.expires_at,});-- const src = ensureSource(offer.id, source);-- hotness.snapshot(offer.id, 'create');-- recordObservation(offer.id, src.id, obsFields, { origin: 'import', observedAt, sourceRevision });-- indexing.emitOffer('deals.offer.created', offer, { actor: 'svc:deals', extra: { imported_from: { service: 'sources', type: 'item', id: source.ref_id } } });-- indexing.reindex(reads.get(offer.id));-- return reads.get(offer.id);++ const src = await ensureSource(offer.id, source);++ await hotness.snapshot(offer.id, 'create');++ await recordObservation(offer.id, src.id, obsFields, { origin: 'import', observedAt, sourceRevision });++ await indexing.emitOffer('deals.offer.created', offer, { actor: 'svc:deals', extra: { imported_from: { service: 'sources', type: 'item', id: source.ref_id } } });++ await indexing.reindex(await reads.get(offer.id));++ return await reads.get(offer.id);}// ── edits and observations ──────────────────────────────-- function update(viewer, idOrSlug, input = {}, { traceparent } = {}) {-- const root = mustRoot(idOrSlug);++ async function update(viewer, idOrSlug, input = {}, { traceparent } = {}) {++ const root = await mustRoot(idOrSlug);const actor = actorOf(viewer);if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can edit it');if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');@@@ -216,33 +216,33 @@ function createOffers(deps) {changes.text_origin = 'ai';changes.review_state = 'pending';}-- return store.tx(() => {++ return await store.tx(async () => {if (input.product_slug !== undefined || input.product !== undefined) {if (!input.product_slug && !input.product) changes.product_id = null;else if (input.product_slug) {-- const p = catalog.productBySlug(input.product_slug);++ const p = await catalog.productBySlug(input.product_slug);if (!p) throw new ApiError(404, 'product.not_found', 'No such product');changes.product_id = p.id;-- } else changes.product_id = catalog.resolve(input.product, { source: 'community', actor }).product.id;++ } else changes.product_id = (await catalog.resolve(input.product, { source: 'community', actor })).product.id;}const keys = Object.keys(changes);if (!keys.length) return { offer: root, changed: false };const oldProduct = root.product_id;-- db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });-- const after = reads.get(root.id);-- indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });-- moderated(viewer, root, 'offer.edited', { details: { changed: keys }, traceparent });-- indexing.reindex(after);-- if (oldProduct && oldProduct !== after.product_id) indexing.indexProduct(catalog.product(oldProduct));++ await db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });++ const after = await reads.get(root.id);++ await indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });++ await moderated(viewer, root, 'offer.edited', { details: { changed: keys }, traceparent });++ await indexing.reindex(after);++ if (oldProduct && oldProduct !== after.product_id) await indexing.indexProduct(await catalog.product(oldProduct));return { offer: after, changed: true };});}/** A person (or a service acting for one) reports what they see at the link right now. */-- function observe(viewer, idOrSlug, input = {}, { ip = null, traceparent } = {}) {++ async function observe(viewer, idOrSlug, input = {}, { ip = null, traceparent } = {}) {const subject = viewer && viewer.subject;if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a price');-- const root = mustRoot(idOrSlug);++ const root = await mustRoot(idOrSlug);if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');const fields = parseObservation(input);if (fields.price == null && fields.shipping == null && !fields.availability && !fields.condition) {@@@ -250,47 +250,47 @@ function createOffers(deps) {}let observedAt = store.now();if (viewer.kind === 'service' && input.observed_at != null) observedAt = parseInstant(input.observed_at, 'observed_at');-- return store.tx(() => {-- limits.check('observe', subject, config.abuse.observePerHour, 3600 * 1000, 'observe.rate_limited');-- const src = ensureSource(root.id, { kind: 'community', ref_service: 'deals', ref_type: 'observer', ref_id: subject, ref_part: root.id, url: root.url, submitted_by: subject });-- const obs = recordObservation(root.id, src.id, fields, { origin: 'community', observedBy: subject, observedAt });-- limits.hit('observe', subject);-- if (ip) limits.hit('observe_ip', ip);-- db.prepare('UPDATE deal_offers SET updated_at = ? WHERE id = ?').run(store.now(), root.id);-- indexing.emitOffer('deals.offer.updated', root, { actor: subject, extra: { changed: ['observation'], observation_id: obs.id }, traceparent });-- indexing.reindex(reads.get(root.id));-- return { offer: reads.get(root.id), observation: obs };++ return await store.tx(async () => {++ await limits.check('observe', subject, config.abuse.observePerHour, 3600 * 1000, 'observe.rate_limited');++ const src = await ensureSource(root.id, { kind: 'community', ref_service: 'deals', ref_type: 'observer', ref_id: subject, ref_part: root.id, url: root.url, submitted_by: subject });++ const obs = await recordObservation(root.id, src.id, fields, { origin: 'community', observedBy: subject, observedAt });++ await limits.hit('observe', subject);++ if (ip) await limits.hit('observe_ip', ip);++ await db.prepare('UPDATE deal_offers SET updated_at = ? WHERE id = ?').run(store.now(), root.id);++ await indexing.emitOffer('deals.offer.updated', root, { actor: subject, extra: { changed: ['observation'], observation_id: obs.id }, traceparent });++ await indexing.reindex(await reads.get(root.id));++ return { offer: await reads.get(root.id), observation: obs };});}// ── expiry and moderation ───────────────────────────────-- function expireRow(root, { actor, reason, traceparent }) {++ async function expireRow(root, { actor, reason, traceparent }) {const now = store.now();-- db.prepare("UPDATE deal_offers SET status = 'expired', expired_at = ?, expired_reason = ?, updated_at = ? WHERE id = ?").run(now, reason, now, root.id);-- logAction('expire', { offerId: root.id, actor: actor || 'svc:deals', reason, before: { status: root.status }, after: { status: 'expired' } });-- const after = reads.get(root.id);-- indexing.emitOffer('deals.offer.expired', after, { actor, extra: { reason }, traceparent });-- indexing.reindex(after);++ await db.prepare("UPDATE deal_offers SET status = 'expired', expired_at = ?, expired_reason = ?, updated_at = ? WHERE id = ?").run(now, reason, now, root.id);++ await logAction('expire', { offerId: root.id, actor: actor || 'svc:deals', reason, before: { status: root.status }, after: { status: 'expired' } });++ const after = await reads.get(root.id);++ await indexing.emitOffer('deals.offer.expired', after, { actor, extra: { reason }, traceparent });++ await indexing.reindex(after);return after;}-- function expire(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {-- const root = mustRoot(idOrSlug);++ async function expire(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {++ const root = await mustRoot(idOrSlug);if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';-- return store.tx(() => {-- const after = expireRow(root, { actor: actorOf(viewer), reason: why, traceparent });-- moderated(viewer, root, 'offer.expired', { reason: text(reason, { max: 200 }) || null, traceparent });++ return await store.tx(async () => {++ const after = await expireRow(root, { actor: actorOf(viewer), reason: why, traceparent });++ await moderated(viewer, root, 'offer.expired', { reason: text(reason, { max: 200 }) || null, traceparent });return after;});}/** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */-- function expireDue() {-- const due = q.dueExpiry.all(store.now());-- store.tx(() => { for (const o of due) expireRow(o, { actor: null, reason: 'stated_expiry' }); });++ async function expireDue() {++ const due = await q.dueExpiry.all(store.now());++ await store.tx(async () => { for (const o of due) await expireRow(o, { actor: null, reason: 'stated_expiry' }); });return due.length;}@@@ -298,19 +298,19 @@ function createOffers(deps) {if (!access.isModerator(viewer, cap)) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');}-- function disable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {++ async function disable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {requireStaff(viewer, 'deals.offer.moderate');-- const root = mustRoot(idOrSlug);++ const root = await mustRoot(idOrSlug);const why = text(reason, { field: 'reason', min: 3, max: 300, required: true });if (root.status === 'disabled') return { offer: root, changed: false };-- const out = store.tx(() => {++ const out = await store.tx(async () => {const now = store.now();-- db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);-- logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });-- moderated(viewer, root, 'offer.disabled', { reason: why, details: { previous: root.status }, traceparent });-- const after = reads.get(root.id);-- indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });-- indexing.reindex(after);++ await db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);++ await logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });++ await moderated(viewer, root, 'offer.disabled', { reason: why, details: { previous: root.status }, traceparent });++ const after = await reads.get(root.id);++ await indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });++ await indexing.reindex(after);return { offer: after, changed: true };});threadVisibility(out.offer, 'hidden');@@@ -322,21 +322,21 @@ function createOffers(deps) {if (community && community.enabled) community.setThreadVisibility(offer, visibility).catch(() => {});}-- function enable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {++ async function enable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {requireStaff(viewer, 'deals.offer.moderate');-- const root = mustRoot(idOrSlug);++ const root = await mustRoot(idOrSlug);if (root.status === 'active') return { offer: root, changed: false };const wasDisabled = root.status === 'disabled';-- const out = store.tx(() => {++ const out = await store.tx(async () => {const now = store.now();const keepExpiry = root.expires_at != null && root.expires_at > now ? root.expires_at : null;-- db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,++ await db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);-- logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });-- moderated(viewer, root, 'offer.enabled', { reason: text(reason, { max: 300 }) || null, details: { previous: root.status }, traceparent });-- const after = reads.get(root.id);-- indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });-- indexing.reindex(after);++ await logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });++ await moderated(viewer, root, 'offer.enabled', { reason: text(reason, { max: 300 }) || null, details: { previous: root.status }, traceparent });++ const after = await reads.get(root.id);++ await indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });++ await indexing.reindex(after);return { offer: after, changed: true };});if (wasDisabled) threadVisibility(out.offer, 'public');@@@ -344,74 +344,74 @@ function createOffers(deps) {}/** A person confirms imported or AI-assisted text (only a usr_ subject can review). */-- function review(viewer, idOrSlug, { note } = {}, { traceparent } = {}) {++ async function review(viewer, idOrSlug, { note } = {}, { traceparent } = {}) {requireStaff(viewer, 'deals.offer.moderate');const reviewer = viewer.subject;if (!reviewer) throw new ApiError(403, 'review.person_required', 'A review is recorded for a person (usr_…), never for a service');-- const root = mustRoot(idOrSlug);++ const root = await mustRoot(idOrSlug);if (root.review_state !== 'pending') return { offer: root, changed: false };-- return store.tx(() => {++ return await store.tx(async () => {const now = store.now();-- db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);-- logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });-- moderated(viewer, root, 'offer.reviewed', { reason: text(note, { max: 300 }) || null, traceparent });-- const after = reads.get(root.id);-- indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });-- indexing.reindex(after);++ await db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);++ await logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });++ await moderated(viewer, root, 'offer.reviewed', { reason: text(note, { max: 300 }) || null, traceparent });++ const after = await reads.get(root.id);++ await indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });++ await indexing.reindex(after);return { offer: after, changed: true };});}-- function merge(viewer, idOrSlug, intoIdOrSlug, { reason } = {}, { traceparent } = {}) {++ async function merge(viewer, idOrSlug, intoIdOrSlug, { reason } = {}, { traceparent } = {}) {requireStaff(viewer, 'deals.offer.merge');-- const dup = reads.mustFind(idOrSlug);-- const target = reads.root(reads.mustFind(intoIdOrSlug));++ const dup = await reads.mustFind(idOrSlug);++ const target = await reads.root(await reads.mustFind(intoIdOrSlug));if (dup.merged_into) throw new ApiError(409, 'offer.already_merged', 'This offer is already merged; unmerge it first');-- if (reads.groupIds(dup.id).includes(target.id)) throw new ApiError(409, 'offer.merge_cycle', 'Cannot merge an offer into itself or into an offer merged into it');++ if ((await reads.groupIds(dup.id)).includes(target.id)) throw new ApiError(409, 'offer.merge_cycle', 'Cannot merge an offer into itself or into an offer merged into it');const actor = actorOf(viewer);-- return store.tx(() => {-- const before = { target: tallies(target), duplicate: tallies(dup) };++ return await store.tx(async () => {++ const before = { target: await tallies(target), duplicate: await tallies(dup) };const now = store.now();-- db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);-- const after = { target: tallies(target) };-- logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });-- moderated(viewer, dup, 'offer.merged', { reason: text(reason, { max: 300 }) || null, details: { into: target.id }, traceparent });-- hotness.snapshot(target.id, 'merge');-- indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });-- indexing.indexOffer(reads.get(dup.id));-- indexing.reindex(reads.get(target.id));-- if (dup.product_id && dup.product_id !== target.product_id) indexing.indexProduct(catalog.product(dup.product_id));-- return { offer: reads.get(target.id), merged: reads.get(dup.id), before, after };++ await db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);++ const after = { target: await tallies(target) };++ await logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });++ await moderated(viewer, dup, 'offer.merged', { reason: text(reason, { max: 300 }) || null, details: { into: target.id }, traceparent });++ await hotness.snapshot(target.id, 'merge');++ await indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });++ await indexing.indexOffer(await reads.get(dup.id));++ await indexing.reindex(await reads.get(target.id));++ if (dup.product_id && dup.product_id !== target.product_id) await indexing.indexProduct(await catalog.product(dup.product_id));++ return { offer: await reads.get(target.id), merged: await reads.get(dup.id), before, after };});}-- function unmerge(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {++ async function unmerge(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {requireStaff(viewer, 'deals.offer.merge');-- const dup = reads.mustFind(idOrSlug);++ const dup = await reads.mustFind(idOrSlug);if (!dup.merged_into) throw new ApiError(409, 'offer.not_merged', 'This offer is not merged');-- const parentRoot = reads.root(dup);++ const parentRoot = await reads.root(dup);const actor = actorOf(viewer);-- return store.tx(() => {-- const before = { target: tallies(parentRoot) };++ return await store.tx(async () => {++ const before = { target: await tallies(parentRoot) };const now = store.now();-- db.prepare('UPDATE deal_offers SET merged_into = NULL, merged_at = NULL, merged_by = NULL, updated_at = ? WHERE id = ?').run(now, dup.id);-- const restored = reads.get(dup.id);-- const after = { target: tallies(parentRoot), duplicate: tallies(restored) };-- logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });-- moderated(viewer, dup, 'offer.unmerged', { reason: text(reason, { max: 300 }) || null, details: { from: parentRoot.id }, traceparent });-- hotness.snapshot(parentRoot.id, 'unmerge');-- hotness.snapshot(restored.id, 'unmerge');-- indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });-- indexing.emitOffer('deals.offer.updated', restored, { actor, extra: { changed: ['unmerged'], unmerged_from: parentRoot.id }, traceparent });-- indexing.reindex(reads.get(parentRoot.id));-- indexing.reindex(restored);-- return { offer: restored, from: reads.get(parentRoot.id), before, after };++ await db.prepare('UPDATE deal_offers SET merged_into = NULL, merged_at = NULL, merged_by = NULL, updated_at = ? WHERE id = ?').run(now, dup.id);++ const restored = await reads.get(dup.id);++ const after = { target: await tallies(parentRoot), duplicate: await tallies(restored) };++ await logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });++ await moderated(viewer, dup, 'offer.unmerged', { reason: text(reason, { max: 300 }) || null, details: { from: parentRoot.id }, traceparent });++ await hotness.snapshot(parentRoot.id, 'unmerge');++ await hotness.snapshot(restored.id, 'unmerge');++ await indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });++ await indexing.emitOffer('deals.offer.updated', restored, { actor, extra: { changed: ['unmerged'], unmerged_from: parentRoot.id }, traceparent });++ await indexing.reindex(await reads.get(parentRoot.id));++ await indexing.reindex(restored);++ return { offer: restored, from: await reads.get(parentRoot.id), before, after };});}-- function moderationLog(root) {-- const ids = JSON.stringify(reads.groupIds(root.id));-- return q.logFor.all(ids, ids);++ async function moderationLog(root) {++ const ids = JSON.stringify(await reads.groupIds(root.id));++ return await q.logFor.all(ids, ids);}return {@@@ -59,22 +59,22 @@ function createPublication({ config, store, reads, catalog }) {}/** Everything a page, the JSON twin, an event or the index needs about one canonical offer. */-- function offerView(offer, now = store.now()) {-- const root = reads.root(offer);-- const ids = reads.groupIds(root.id);-- const latest = reads.latestObservation(ids);-- const tally = reads.tally(ids);-- const snap = reads.lastSnapshot(root.id);++ async function offerView(offer, now = store.now()) {++ const root = await reads.root(offer);++ const ids = await reads.groupIds(root.id);++ const latest = await reads.latestObservation(ids);++ const tally = await reads.tally(ids);++ const snap = await reads.lastSnapshot(root.id);return {root,ids,-- store: catalog.store(root.store_id),-- product: catalog.product(root.product_id),-- observations: reads.observations(ids),++ store: await catalog.store(root.store_id),++ product: await catalog.product(root.product_id),++ observations: await reads.observations(ids),latest,freshness: freshness(latest, now),-- sources: reads.sources(ids),-- members: ids.filter((id) => id !== root.id).map((id) => reads.get(id)),++ sources: await reads.sources(ids),++ members: await Promise.all(ids.filter((id) => id !== root.id).map(async (id) => await reads.get(id))),tally,hotness: snap,decision: decideOffer(root, latest, now),@@@ -158,15 +158,15 @@ function createPublication({ config, store, reads, catalog }) {* price ascending), then fresh offers without a price, then stale / unobserved / expired ones —* an old or unknown price never sorts as if it were the price now, and unknown never sorts as 0.*/-- function productView(product, now = store.now()) {-- const rows = productOfferIds.all(product.id).map((r) => offerView(reads.get(r.id), now));++ async function productView(product, now = store.now()) {++ const rows = await Promise.all((await productOfferIds.all(product.id)).map(async (r) => await offerView(await reads.get(r.id), now)));const rank = (v) => (v.root.status !== 'active' ? 4 : v.freshness.state !== 'fresh' ? 3 : (v.latest && v.latest.price != null && v.latest.currency) ? 1 : 2);rows.sort((a, b) => rank(a) - rank(b)|| (rank(a) === 1 ? (a.latest.currency < b.latest.currency ? -1 : a.latest.currency > b.latest.currency ? 1 : a.latest.price_num - b.latest.price_num) : 0)|| b.root.created_at - a.root.created_at);const fresh = rows.filter((v) => v.root.status === 'active' && v.freshness.state === 'fresh').map((v) => v.latest.observed_at);const freshest = fresh.length ? Math.max(...fresh) : null;-- return { product, offers: rows, aliases: catalog.aliases(product.id), decision: decideProduct(product, freshest, now), freshestObservedAt: freshest };++ return { product, offers: rows, aliases: await catalog.aliases(product.id), decision: decideProduct(product, freshest, now), freshestObservedAt: freshest };}function productJsonLd(pv) {@@@ -15,58 +15,58 @@ function createReads({ store }) {const q = {byId: db.prepare('SELECT * FROM deal_offers WHERE id = ?'),bySlug: db.prepare('SELECT * FROM deal_offers WHERE slug = ?'),-- group: db.prepare(`WITH RECURSIVE g(id, depth) AS (SELECT ?, 0 UNION SELECT o.id, g.depth + 1 FROM deal_offers o JOIN g ON o.merged_into = g.id WHERE g.depth < 50)++ group: db.prepare(`WITH RECURSIVE g(id, depth) AS (SELECT ?::text COLLATE "C", 0 UNION SELECT o.id, g.depth + 1 FROM deal_offers o JOIN g ON o.merged_into = g.id WHERE g.depth < 50)SELECT id FROM g`),-- observations: db.prepare(`SELECT o.*, o.rowid AS seq, s.kind AS source_kind, s.ref_service, s.ref_type, s.ref_id, s.ref_revision, s.source_key,++ observations: db.prepare(`SELECT o.*, s.kind AS source_kind, s.ref_service, s.ref_type, s.ref_id, s.ref_revision, s.source_key,s.url AS source_url, s.label AS source_label, s.license_note, s.removed_at AS source_removed_atFROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id-- WHERE o.offer_id IN (SELECT value FROM json_each(?))-- ORDER BY o.observed_at DESC, o.rowid DESC`),++ WHERE o.offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb))++ ORDER BY o.observed_at DESC, o.seq DESC`),latest: db.prepare(`SELECT o.*, s.kind AS source_kind, s.ref_service, s.ref_id, s.source_key, s.url AS source_url, s.label AS source_labelFROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id-- WHERE o.offer_id IN (SELECT value FROM json_each(?)) AND s.removed_at IS NULL-- ORDER BY o.observed_at DESC, o.rowid DESC LIMIT 1`),-- sources: db.prepare(`SELECT * FROM deal_offer_sources WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY created_at, rowid`),-- votes: db.prepare(`SELECT offer_id, subject, value, weight, ip_hash, updated_at, rowid AS seq FROM deal_votes-- WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY updated_at, rowid`),++ WHERE o.offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) AND s.removed_at IS NULL++ ORDER BY o.observed_at DESC, o.seq DESC LIMIT 1`),++ sources: db.prepare(`SELECT * FROM deal_offer_sources WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY created_at, seq`),++ votes: db.prepare(`SELECT offer_id, subject, value, weight, ip_hash, updated_at, seq FROM deal_votes++ WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY updated_at, seq`),members: db.prepare('SELECT * FROM deal_offers WHERE merged_into = ? ORDER BY merged_at'),lastSnapshot: db.prepare('SELECT * FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id DESC LIMIT 1'),};-- const get = (id) => (id ? q.byId.get(String(id)) : null);++ const get = async (id) => (id ? await q.byId.get(String(id)) : null);/** By id (dof_…) or slug. */-- function find(idOrSlug) {++ async function find(idOrSlug) {const s = String(idOrSlug || '');-- return s.startsWith('dof_') ? get(s) : q.bySlug.get(s) || null;++ return s.startsWith('dof_') ? await get(s) : await q.bySlug.get(s) || null;}-- function mustFind(idOrSlug) {-- const o = find(idOrSlug);++ async function mustFind(idOrSlug) {++ const o = await find(idOrSlug);if (!o) throw new ApiError(404, 'offer.not_found', 'No such offer');return o;}/** The canonical offer a (possibly merged) offer resolves to. */-- function root(offer) {++ async function root(offer) {let cur = offer;-- for (let i = 0; cur && cur.merged_into && i < 50; i++) cur = get(cur.merged_into);++ for (let i = 0; cur && cur.merged_into && i < 50; i++) cur = await get(cur.merged_into);return cur;}-- const groupIds = (rootId) => q.group.all(rootId).map((r) => r.id);++ const groupIds = async (rootId) => (await q.group.all(rootId)).map((r) => r.id);const J = (list) => JSON.stringify(list);/** Each subject's effective vote in a group: their most recent row (0 = removed). */-- function effectiveVotes(ids) {++ async function effectiveVotes(ids) {const bySubject = new Map();-- for (const v of q.votes.all(J(ids))) bySubject.set(v.subject, v);++ for (const v of await q.votes.all(J(ids))) bySubject.set(v.subject, v);return bySubject;}-- function tally(ids) {++ async function tally(ids) {let up = 0, down = 0, upW = 0, downW = 0;-- for (const v of effectiveVotes(ids).values()) {++ for (const v of (await effectiveVotes(ids)).values()) {if (v.value === 1) { up++; upW += v.weight; } else if (v.value === -1) { down++; downW += v.weight; }}const r4 = (x) => Math.round(x * 10000) / 10000;@@@ -79,13 +79,13 @@ function createReads({ store }) {mustFind,root,groupIds,-- observations: (ids) => q.observations.all(J(ids)),-- latestObservation: (ids) => q.latest.get(J(ids)) || null,-- sources: (ids) => q.sources.all(J(ids)),++ observations: async (ids) => await q.observations.all(J(ids)),++ latestObservation: async (ids) => await q.latest.get(J(ids)) || null,++ sources: async (ids) => await q.sources.all(J(ids)),effectiveVotes,tally,-- mergedMembers: (id) => q.members.all(id),-- lastSnapshot: (id) => q.lastSnapshot.get(id) || null,++ mergedMembers: async (id) => await q.members.all(id),++ lastSnapshot: async (id) => await q.lastSnapshot.get(id) || null,};}@@@ -40,38 +40,38 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, outVALUES (@offer_id, @subject, @value, @weight, @ip_hash, @via, @now, @at)ON CONFLICT (offer_id, subject) DO UPDATE SET value = excluded.value, weight = excluded.weight,ip_hash = COALESCE(excluded.ip_hash, deal_votes.ip_hash), via = excluded.via, updated_at = excluded.updated_at`),-- lastInGroup: db.prepare('SELECT MAX(updated_at) AS t FROM deal_votes WHERE subject = ? AND offer_id IN (SELECT value FROM json_each(?))'),-- sameIp: db.prepare(`SELECT DISTINCT subject FROM deal_votes WHERE ip_hash = ? AND value <> 0 AND offer_id IN (SELECT value FROM json_each(?)) ORDER BY subject`),++ lastInGroup: db.prepare('SELECT MAX(updated_at) AS t FROM deal_votes WHERE subject = ? AND offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb))'),++ sameIp: db.prepare(`SELECT DISTINCT subject FROM deal_votes WHERE ip_hash = ? AND value <> 0 AND offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY subject`),covote: db.prepare(`SELECT b.subject AS other, COUNT(DISTINCT a.offer_id) AS nFROM deal_votes a JOIN deal_votes bON b.offer_id = a.offer_id AND b.subject <> a.subject AND b.value = a.value AND a.value <> 0AND ABS(a.updated_at - b.updated_at) <= @win-- WHERE a.subject = @s GROUP BY b.subject HAVING n >= @min ORDER BY b.subject`),++ WHERE a.subject = @s GROUP BY b.subject HAVING COUNT(DISTINCT a.offer_id) >= @min ORDER BY b.subject`),};-- function weightFor(subject, now = store.now()) {++ async function weightFor(subject, now = store.now()) {const minted = subjectTime(subject);-- const seen = people.firstSeen(subject);++ const seen = await people.firstSeen(subject);const evidence = Math.max(minted != null ? now - minted : 0, seen != null ? now - seen : 0);return evidence >= config.abuse.newAccountDays * DAY ? 1 : config.abuse.newAccountWeight;}-- function detectRings(root, ids, subject, ipHash) {++ async function detectRings(root, ids, subject, ipHash) {const raised = [];if (ipHash) {-- const subjects = q.sameIp.all(ipHash, JSON.stringify(ids)).map((r) => r.subject);++ const subjects = (await q.sameIp.all(ipHash, JSON.stringify(ids))).map((r) => r.subject);if (subjects.length >= config.abuse.ringIpMin) {-- raised.push(flags.raise({++ raised.push(await flags.raise({kind: 'vote_ring', offerId: root.id, key: `ring:ip:${root.id}:${ipHash}`, reason: 'shared_ip',details: { signal: 'shared_ip', ip_hash: ipHash, voters: subjects },}));}}-- const partners = q.covote.all({ s: subject, win: config.abuse.ringWindowMs, min: config.abuse.ringCovoteMin });++ const partners = await q.covote.all({ s: subject, win: config.abuse.ringWindowMs, min: config.abuse.ringCovoteMin });if (partners.length) {const voters = [subject, ...partners.map((p) => p.other)].sort();const key = `ring:covote:${crypto.createHash('sha256').update(voters.join(',')).digest('hex').slice(0, 20)}`;-- raised.push(flags.raise({++ raised.push(await flags.raise({kind: 'vote_ring', offerId: root.id, key, reason: 'covote',details: { signal: 'covote', voters, shared_offers: Object.fromEntries(partners.map((p) => [p.other, p.n])), window_minutes: config.abuse.ringWindowMs / 60000 },}));@@@ -80,36 +80,36 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, out}/** value: 1 | -1 (set) or 0 (remove). */-- function apply(viewer, idOrSlug, value, { ip = null, traceparent } = {}) {++ async function apply(viewer, idOrSlug, value, { ip = null, traceparent } = {}) {const subject = viewer && viewer.subject;if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to vote');if (![1, -1, 0].includes(value)) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');-- const listing = reads.mustFind(idOrSlug);-- const root = reads.root(listing);++ const listing = await reads.mustFind(idOrSlug);++ const root = await reads.root(listing);if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');-- const ids = reads.groupIds(root.id);-- if (ids.some((id) => { const o = reads.get(id); return o && o.submitted_by === subject; })) {++ const ids = await reads.groupIds(root.id);++ if ((await Promise.all(ids.map(async (id) => await reads.get(id)))).some((o) => o && o.submitted_by === subject)) {throw new ApiError(403, 'vote.own_offer', 'You cannot vote on a deal you posted');}const ipHash = limits.ipHash(ip);-- return store.tx(() => {-- const current = reads.effectiveVotes(ids).get(subject);++ return await store.tx(async () => {++ const current = (await reads.effectiveVotes(ids)).get(subject);const previous = current ? current.value : 0;-- if (previous === value) return { changed: false, value, previous, tally: reads.tally(ids), root };-- limits.check('vote', subject, config.abuse.voteSubjectPerHour, 3600 * 1000, 'vote.rate_limited');-- if (ipHash) limits.check('vote_ip', ipHash, config.abuse.voteIpPerHour, 3600 * 1000, 'vote.rate_limited');-- limits.check('vote_offer', `${subject}:${root.id}`, config.abuse.voteOfferChangesPerHour, 3600 * 1000, 'vote.rate_limited');++ if (previous === value) return { changed: false, value, previous, tally: await reads.tally(ids), root };++ await limits.check('vote', subject, config.abuse.voteSubjectPerHour, 3600 * 1000, 'vote.rate_limited');++ if (ipHash) await limits.check('vote_ip', ipHash, config.abuse.voteIpPerHour, 3600 * 1000, 'vote.rate_limited');++ await limits.check('vote_offer', `${subject}:${root.id}`, config.abuse.voteOfferChangesPerHour, 3600 * 1000, 'vote.rate_limited');const now = store.now();-- const last = q.lastInGroup.get(subject, JSON.stringify(ids)).t;-- const weight = weightFor(subject, now);++ const last = (await q.lastInGroup.get(subject, JSON.stringify(ids))).t;++ const weight = await weightFor(subject, now);// updated_at strictly after the person's previous row in the group: "most recent" is exact.-- q.upsert.run({ offer_id: root.id, subject, value, weight, ip_hash: ipHash, via: viewer.kind === 'service' ? 'service' : 'user', now, at: last != null && last >= now ? last + 1 : now });-- limits.hit('vote', subject);-- if (ipHash) limits.hit('vote_ip', ipHash);-- limits.hit('vote_offer', `${subject}:${root.id}`);-- const snap = hotness.snapshot(root.id, 'vote', now);-- const tally = reads.tally(ids);-- outbox.emit({++ await q.upsert.run({ offer_id: root.id, subject, value, weight, ip_hash: ipHash, via: viewer.kind === 'service' ? 'service' : 'user', now, at: last != null && last >= now ? last + 1 : now });++ await limits.hit('vote', subject);++ if (ipHash) await limits.hit('vote_ip', ipHash);++ await limits.hit('vote_offer', `${subject}:${root.id}`);++ const snap = await hotness.snapshot(root.id, 'vote', now);++ const tally = await reads.tally(ids);++ await outbox.emit({event_type: 'deals.vote.changed',version: 1,source: 'deals',@@@ -122,20 +122,20 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, outhotness: { formula: snap.formula, hot: snap.hot, score: snap.score },},}, { traceparent });-- const rings = value !== 0 ? detectRings(root, ids, subject, ipHash) : [];++ const rings = value !== 0 ? await detectRings(root, ids, subject, ipHash) : [];return { changed: true, value, previous, weight, tally, hotness: snap, rings, root };});}-- function mine(subject, root) {++ async function mine(subject, root) {if (!subject || !root) return 0;-- const v = reads.effectiveVotes(reads.groupIds(root.id)).get(subject);++ const v = (await reads.effectiveVotes(await reads.groupIds(root.id))).get(subject);return v ? v.value : 0;}return {-- set: (viewer, id, value, opts) => apply(viewer, id, Number(value), opts),-- remove: (viewer, id, opts) => apply(viewer, id, 0, opts),++ set: async (viewer, id, value, opts) => await apply(viewer, id, Number(value), opts),++ remove: async (viewer, id, opts) => await apply(viewer, id, 0, opts),mine,weightFor,};@@@ -35,22 +35,22 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {VALUES (@id, @subject, @kind, @query, @product_id, @max_price, @max_price_num, @currency, @notify, @label, @now)`),remove: db.prepare('UPDATE deal_watches SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL'),active: db.prepare("SELECT * FROM deal_watches WHERE notify = 1 AND deleted_at IS NULL AND kind <> 'search'"),-- claim: db.prepare(`INSERT OR IGNORE INTO watch_notifications (watch_id, observation_id, offer_id, price_num, currency, created_at) VALUES (?, ?, ?, ?, ?, ?)`),++ claim: db.prepare(`INSERT INTO watch_notifications (watch_id, observation_id, offer_id, price_num, currency, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),setEvent: db.prepare('UPDATE watch_notifications SET event_id = ? WHERE watch_id = ? AND observation_id = ?'),priorForGroup: db.prepare(`SELECT price_num, currency FROM watch_notifications-- WHERE watch_id = ? AND offer_id IN (SELECT value FROM json_each(?)) AND event_id IS NOT NULL`),++ WHERE watch_id = ? AND offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) AND event_id IS NOT NULL`),notifications: db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE watch_id = ? AND event_id IS NOT NULL'),};-- function create(subject, input) {++ async function create(subject, input) {if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to save watches');const kind = String(input.kind || '');if (!KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${KINDS.join(', ')}`);-- if (q.countFor.get(subject).n >= config.abuse.watchesPerSubject) throw new ApiError(429, 'watch.limit', `At most ${config.abuse.watchesPerSubject} watches and saved searches`);++ if ((await q.countFor.get(subject)).n >= config.abuse.watchesPerSubject) throw new ApiError(429, 'watch.limit', `At most ${config.abuse.watchesPerSubject} watches and saved searches`);const query = text(input.query, { field: 'query', max: 200 });let product = null;if (input.product) {-- product = catalog.productBySlug(input.product) || catalog.product(input.product);++ product = await catalog.productBySlug(input.product) || await catalog.product(input.product);if (!product) throw new ApiError(404, 'product.not_found', 'No such product');}const row = { id: newId('dwt'), subject, kind, query: null, product_id: null, max_price: null, max_price_num: null, currency: null, notify: kind === 'search' ? 0 : 1, label: text(input.label, { max: 120 }), now: store.now() };@@@ -67,14 +67,14 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {if (!product && !(query && tokens(query).length)) throw new ApiError(422, 'request.invalid', 'a price watch needs a product or keywords');Object.assign(row, { product_id: product ? product.id : null, query: product ? null : query, max_price: amount.text, max_price_num: amount.num, currency });}-- q.insert.run(row);-- return q.get.get(row.id);++ await q.insert.run(row);++ return await q.get.get(row.id);}-- function remove(subject, id, { staff = false } = {}) {-- const w = q.get.get(String(id || ''));++ async function remove(subject, id, { staff = false } = {}) {++ const w = await q.get.get(String(id || ''));if (!w || w.deleted_at || (w.subject !== subject && !staff)) throw new ApiError(404, 'watch.not_found', 'No such watch');-- q.remove.run(store.now(), w.id);++ await q.remove.run(store.now(), w.id);return { id: w.id, deleted: true };}@@@ -95,23 +95,23 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {}/** Inside the observation's transaction. Returns the envelopes emitted. */-- function onObservation(obs) {-- const offer = reads.get(obs.offer_id);-- const v = publication.offerView(offer);++ async function onObservation(obs) {++ const offer = await reads.get(obs.offer_id);++ const v = await publication.offerView(offer);if (v.root.status !== 'active') return [];if (store.now() - obs.observed_at > config.freshnessMs) return [];const words = offerText(v);const out = [];-- for (const w of q.active.all()) {++ for (const w of await q.active.all()) {if (obs.observed_by && obs.observed_by === w.subject) continue;if (v.root.submitted_by && v.root.submitted_by === w.subject && obs.origin === 'community') continue;if (!matches(w, v, obs, words)) continue;-- const prior = q.priorForGroup.all(w.id, JSON.stringify(v.ids));++ const prior = await q.priorForGroup.all(w.id, JSON.stringify(v.ids));if (w.kind !== 'price_below' && prior.length) continue;if (w.kind === 'price_below' && prior.some((p) => p.currency === obs.currency && p.price_num != null && p.price_num <= obs.price_num)) continue;// The uniqueness: at most one row, hence one event, per (watch, observation).-- if (q.claim.run(w.id, obs.id, v.root.id, obs.price_num, obs.currency, store.now()).changes === 0) continue;-- const env = outbox.emit({++ if ((await q.claim.run(w.id, obs.id, v.root.id, obs.price_num, obs.currency, store.now())).changes === 0) continue;++ const env = await outbox.emit({event_type: 'deals.watch.matched',version: 1,source: 'deals',@@@ -125,23 +125,23 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {observation: { id: obs.id, observed_at: iso(obs.observed_at), price: obs.price, currency: obs.currency, availability: obs.availability },},});-- q.setEvent.run(env.event_id, w.id, obs.id);++ await q.setEvent.run(env.event_id, w.id, obs.id);out.push(env);}return out;}-- function dto(w) {-- const product = w.product_id ? catalog.product(w.product_id) : null;++ async function dto(w) {++ const product = w.product_id ? await catalog.product(w.product_id) : null;return {id: w.id, kind: w.kind, query: w.query, label: w.label, notify: Boolean(w.notify),product: product ? { id: product.id, slug: product.slug, name: product.name, url: publication.abs(publication.productPath(product)) } : null,max_price: w.max_price, currency: w.currency, created_at: iso(w.created_at),-- notifications: q.notifications.get(w.id).n,++ notifications: (await q.notifications.get(w.id)).n,};}-- return { create, remove, onObservation, list: (subject) => q.listFor.all(subject), get: (id) => q.get.get(id), dto, KINDS };++ return { create, remove, onObservation, list: async (subject) => await q.listFor.all(subject), get: async (id) => await q.get.get(id), dto, KINDS };}module.exports = { createWatches, KINDS };@@@ -23,7 +23,7 @@*/const { createClient } = require('openvibe-sdk/core');const { createServiceTokenClient } = require('openvibe-sdk/auth');--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {const enabled = Boolean(config.events.url && config.oauth.clientSecret);@@@ -39,7 +39,9 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {}const events = createEventsClient(createClient(clientOpts), { source: 'deals' });let lastError = null;-- const outbox = createOutbox(db, {++ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient++ // transaction); several processes relay one table safely (leases).++ const outbox = createPgOutbox(db, {events,intervalMs: config.events.intervalMs,now,@@@ -49,20 +51,19 @@ function createDealsOutbox({ 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 });}/*** deals.moderation.action, inside the caller's transaction. actorSubject: the moderator (null for* a service acting without a person); target: { type, id, owner_subject? }. Never the content.*/-- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {++ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };-- return emit({++ return await emit({event_type: 'deals.moderation.action',actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'deals' },subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },@@@ -78,8 +79,8 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {enabled,start() { if (enabled) outbox.start(); },stop: () => outbox.stop(),-- kick() { if (enabled) outbox.kick(); },-- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ async kick() { if (enabled) await outbox.kick(); },++ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),};}@@@ -28,7 +28,7 @@* deliveries at /internal/events (Sources wake-ups: Events pushes at its own pace, and a 429 would only* make it retry and fall behind), and the pages and feeds people read.*/--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');const FIRST_PARTY = /^svc:/;const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;@@@ -85,7 +85,7 @@ const BUDGETS = {* limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on every GET/HEAD,* a first-party service reading for itself not counted) 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: 'deals_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -93,6 +93,10 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lolimits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },actor,now,++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),++ // 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(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);@@@ -61,31 +61,31 @@ function createApi(ctx) {router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });const opts = (req) => ({ ip: req.viewer.kind === 'service' ? null : req.ip, traceparent: req.ov && req.ov.traceparent });-- const offerOut = (offer) => ({ offer: publication.offerDto(publication.offerView(offer)) });++ const offerOut = async (offer) => ({ offer: publication.offerDto(await publication.offerView(offer)) });const needSubject = (req) => { if (!req.viewer.subject) throw new ApiError(req.viewer.kind === 'service' ? 400 : 401, req.viewer.kind === 'service' ? 'subject.required' : 'auth.required', req.viewer.kind === 'service' ? 'X-OV-Subject must name the person this action is for' : 'Sign in first'); };// ── offers ──────────────────────────────────────────────-- router.get('/offers', run((req) => {++ router.get('/offers', run(async (req) => {const page = Math.max(1, parseInt(req.query.page, 10) || 1);const per = 25;-- const rows = req.query.sort === 'new' ? listings.newest(per, (page - 1) * per) : listings.hot(per, (page - 1) * per);-- return { sort: req.query.sort === 'new' ? 'new' : 'hot', page, total: listings.countActive(), offers: rows.map((o) => publication.offerDto(publication.offerView(o))) };++ const rows = req.query.sort === 'new' ? await listings.newest(per, (page - 1) * per) : await listings.hot(per, (page - 1) * per);++ return { sort: req.query.sort === 'new' ? 'new' : 'hot', page, total: await listings.countActive(), offers: await Promise.all(rows.map(async (o) => publication.offerDto(await publication.offerView(o)))) };}));// A removed deal answers like its page (410, reason only); moderators still read the whole record.-- router.get('/offers/:id', run((req) => {-- const offer = reads.mustFind(req.params.id);-- const root = reads.root(offer);++ router.get('/offers/:id', run(async (req) => {++ const offer = await reads.mustFind(req.params.id);++ const root = await reads.root(offer);if (root.status === 'disabled' && !access.isModerator(req.viewer)) {throw new ApiError(410, 'offer.disabled', 'This deal was removed by moderators', { id: root.id, slug: root.slug, status: 'disabled', reason: root.disabled_reason });}-- return offerOut(offer);++ return await offerOut(offer);}));-- router.get('/offers/:id/hotness', run((req) => {-- const root = reads.root(reads.mustFind(req.params.id));-- const snap = reads.lastSnapshot(root.id);++ router.get('/offers/:id/hotness', run(async (req) => {++ const root = await reads.root(await reads.mustFind(req.params.id));++ const snap = await reads.lastSnapshot(root.id);return {offer_id: root.id,formula: 'hot@1: score = upWeight − downWeight; hot = round6(score / (ageHours + 2)^1.5), ageHours = (t − firstSeenAt) / 3600000',@@@ -94,110 +94,110 @@ function createApi(ctx) {};}));-- router.post('/offers', guard('deals.offer.submit'), B('deals.offer.submit'), jsonBody, run((req) => {++ router.post('/offers', guard('deals.offer.submit'), B('deals.offer.submit'), jsonBody, run(async (req) => {needSubject(req);-- const out = offers.submit(req.viewer, req.body || {}, opts(req));-- return offerOut(out.offer);++ const out = await offers.submit(req.viewer, req.body || {}, opts(req));++ return await offerOut(out.offer);}, 201));-- router.patch('/offers/:id', guard('deals.offer.update'), B('deals.offer.update'), jsonBody, run((req) => offerOut(offers.update(req.viewer, req.params.id, req.body || {}, opts(req)).offer)));++ router.patch('/offers/:id', guard('deals.offer.update'), B('deals.offer.update'), jsonBody, run(async (req) => await offerOut((await offers.update(req.viewer, req.params.id, req.body || {}, opts(req))).offer)));-- router.post('/offers/:id/observations', guard('deals.offer.update'), B('deals.offer.observe'), jsonBody, run((req) => {++ router.post('/offers/:id/observations', guard('deals.offer.update'), B('deals.offer.observe'), jsonBody, run(async (req) => {needSubject(req);-- const out = offers.observe(req.viewer, req.params.id, req.body || {}, opts(req));-- return { ...offerOut(out.offer), observation: publication.observationDto({ ...out.observation, source_kind: 'community' }) };++ const out = await offers.observe(req.viewer, req.params.id, req.body || {}, opts(req));++ return { ...await offerOut(out.offer), observation: publication.observationDto({ ...out.observation, source_kind: 'community' }) };}, 201));-- router.post('/offers/:id/expire', guard('deals.offer.expire'), B('deals.offer.update'), jsonBody, run((req) => offerOut(offers.expire(req.viewer, req.params.id, req.body || {}, opts(req)))));++ router.post('/offers/:id/expire', guard('deals.offer.expire'), B('deals.offer.update'), jsonBody, run(async (req) => await offerOut(await offers.expire(req.viewer, req.params.id, req.body || {}, opts(req)))));-- router.put('/offers/:id/vote', guard('deals.vote.set'), B('deals.vote'), jsonBody, run((req) => {++ router.put('/offers/:id/vote', guard('deals.vote.set'), B('deals.vote'), jsonBody, run(async (req) => {needSubject(req);const value = Number((req.body || {}).value);if (value !== 1 && value !== -1) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');-- const r = votes.set(req.viewer, req.params.id, value, opts(req));++ const r = await votes.set(req.viewer, req.params.id, value, opts(req));return { offer_id: r.root.id, value: r.value, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };}));-- router.delete('/offers/:id/vote', guard('deals.vote.remove'), B('deals.vote'), run((req) => {++ router.delete('/offers/:id/vote', guard('deals.vote.remove'), B('deals.vote'), run(async (req) => {needSubject(req);-- const r = votes.remove(req.viewer, req.params.id, opts(req));++ const r = await votes.remove(req.viewer, req.params.id, opts(req));return { offer_id: r.root.id, value: 0, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };}));-- router.post('/offers/:id/flags', guard('deals.flag.create'), B('deals.flag.create'), jsonBody, run((req) => {++ router.post('/offers/:id/flags', guard('deals.flag.create'), B('deals.flag.create'), jsonBody, run(async (req) => {needSubject(req);-- const r = flags.report(req.viewer, req.params.id, req.body || {}, opts(req));++ const r = await flags.report(req.viewer, req.params.id, req.body || {}, opts(req));return { flag: { id: r.flag.id, kind: r.flag.kind, status: r.flag.status }, created: r.created };}, (out) => (out.created ? 201 : 200)));-- router.post('/offers/:id/merge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run((req) => {++ router.post('/offers/:id/merge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run(async (req) => {const b = req.body || {};-- const r = offers.merge(req.viewer, req.params.id, b.into, { reason: b.reason }, opts(req));-- return { ...offerOut(r.offer), merged: { id: r.merged.id, slug: r.merged.slug }, before: r.before, after: r.after };++ const r = await offers.merge(req.viewer, req.params.id, b.into, { reason: b.reason }, opts(req));++ return { ...await offerOut(r.offer), merged: { id: r.merged.id, slug: r.merged.slug }, before: r.before, after: r.after };}));-- router.post('/offers/:id/unmerge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run((req) => {-- const r = offers.unmerge(req.viewer, req.params.id, req.body || {}, opts(req));-- return { ...offerOut(r.offer), from: { id: r.from.id, slug: r.from.slug }, before: r.before, after: r.after };++ router.post('/offers/:id/unmerge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run(async (req) => {++ const r = await offers.unmerge(req.viewer, req.params.id, req.body || {}, opts(req));++ return { ...await offerOut(r.offer), from: { id: r.from.id, slug: r.from.slug }, before: r.before, after: r.after };}));for (const action of ['disable', 'enable', 'review']) {-- router.post(`/offers/:id/${action}`, guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run((req) => offerOut(offers[action](req.viewer, req.params.id, req.body || {}, opts(req)).offer)));++ router.post(`/offers/:id/${action}`, guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run(async (req) => await offerOut((await offers[action](req.viewer, req.params.id, req.body || {}, opts(req))).offer)));}-- router.get('/flags', guard('deals.offer.moderate'), run((req) => {++ router.get('/flags', guard('deals.offer.moderate'), run(async (req) => {if (!access.isModerator(req.viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');const status = ['open', 'resolved', 'dismissed'].includes(req.query.status) ? req.query.status : 'open';-- return { flags: flags.list({ status }).map(flags.dto) };++ return { flags: await Promise.all((await flags.list({ status })).map(flags.dto)) };}));-- router.post('/flags/:id/resolve', guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run((req) => {-- const r = flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));-- return { flag: flags.dto(r.flag), changed: r.changed };++ router.post('/flags/:id/resolve', guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run(async (req) => {++ const r = await flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));++ return { flag: await flags.dto(r.flag), changed: r.changed };}));// ── products and stores ─────────────────────────────────-- router.get('/products/:slug', run((req) => {-- const p = catalog.productBySlug(req.params.slug) || catalog.product(req.params.slug);++ router.get('/products/:slug', run(async (req) => {++ const p = await catalog.productBySlug(req.params.slug) || await catalog.product(req.params.slug);if (!p) throw new ApiError(404, 'product.not_found', 'No such product');-- const pv = publication.productView(p);++ const pv = await publication.productView(p);return {product: { id: p.id, slug: p.slug, name: p.name, brand: p.brand, category: p.category, url: publication.abs(publication.productPath(p)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },offers: pv.offers.map((v) => publication.offerDto(v)),};}));-- router.post('/products/resolve', guard('deals.product.resolve'), B('deals.product.resolve'), jsonBody, run((req) => {++ router.post('/products/resolve', guard('deals.product.resolve'), B('deals.product.resolve'), jsonBody, run(async (req) => {const b = req.body || {};const actor = req.viewer.subject || req.viewer.service || null;if (!actor) throw new ApiError(401, 'auth.required', 'Sign in first');const create = b.create !== false;-- const r = ctx.store.tx(() => {-- const out = catalog.resolve(b, { source: req.viewer.kind === 'service' ? 'import' : 'community', actor, create });-- if (out.created) ctx.indexing.indexProduct(out.product);++ const r = await ctx.store.tx(async () => {++ const out = await catalog.resolve(b, { source: req.viewer.kind === 'service' ? 'import' : 'community', actor, create });++ if (out.created) await ctx.indexing.indexProduct(out.product);return out;});if (!r.product) throw new ApiError(404, 'product.not_found', 'No product has these aliases');return { product: { id: r.product.id, slug: r.product.slug, name: r.product.name, url: publication.abs(publication.productPath(r.product)) }, created: r.created, conflicts: r.conflicts };}, (out) => (out.created ? 201 : 200)));-- router.get('/stores/:domain', run((req) => {-- const st = catalog.storeByDomain(req.params.domain);++ router.get('/stores/:domain', run(async (req) => {++ const st = await catalog.storeByDomain(req.params.domain);if (!st) throw new ApiError(404, 'store.not_found', 'No such store');-- return { store: { id: st.id, domain: st.domain, name: st.name, url: publication.abs(publication.storePath(st)) }, offers: listings.byStore(st.id, 50, 0).map((o) => publication.offerDto(publication.offerView(o))) };++ return { store: { id: st.id, domain: st.domain, name: st.name, url: publication.abs(publication.storePath(st)) }, offers: await Promise.all((await listings.byStore(st.id, 50, 0)).map(async (o) => publication.offerDto(await publication.offerView(o)))) };}));// ── watches ─────────────────────────────────────────────-- router.get('/watches', guard('deals.watch.read'), run((req) => { needSubject(req); return { watches: watches.list(req.viewer.subject).map(watches.dto) }; }));++ router.get('/watches', guard('deals.watch.read'), run(async (req) => { needSubject(req); return { watches: await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto)) }; }));-- router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run((req) => {++ router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run(async (req) => {needSubject(req);-- return { watch: watches.dto(watches.create(req.viewer.subject, req.body || {})) };++ return { watch: await watches.dto(await watches.create(req.viewer.subject, req.body || {})) };}, 201));-- router.delete('/watches/:id', guard('deals.watch.delete'), B('deals.watch'), run((req) => { needSubject(req); return watches.remove(req.viewer.subject, req.params.id); }));++ router.delete('/watches/:id', guard('deals.watch.delete'), B('deals.watch'), run(async (req) => { needSubject(req); return await watches.remove(req.viewer.subject, req.params.id); }));router.use((req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));@@@ -23,17 +23,17 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {const abs = (p) => seo.canonicalUrl(config.baseUrl, p);const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);-- function offerEntries() {-- return listings.indexable().map((o) => {-- const v = publication.offerView(o);++ async function offerEntries() {++ return Promise.all((await listings.indexable()).map(async (o) => {++ const v = await publication.offerView(o);return { loc: abs(publication.offerPath(o)), lastmod: v.latest ? Math.max(v.latest.observed_at, o.updated_at) : o.updated_at, decision: v.decision };-- });++ }));}-- function productEntries() {-- return listings.products().map((p) => {-- const pv = publication.productView(p);++ async function productEntries() {++ return Promise.all((await listings.products()).map(async (p) => {++ const pv = await publication.productView(p);return { loc: abs(publication.productPath(p)), lastmod: pv.freshestObservedAt || undefined, decision: pv.decision };-- });++ }));}router.get('/robots.txt', (_req, res) => {@@@ -66,15 +66,15 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {}));});-- router.get('/sitemap.xml', (_req, res) => {-- const o = offerEntries().filter((e) => e.decision.indexable).map((e) => e.lastmod);-- const p = productEntries().filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);++ router.get('/sitemap.xml', async (_req, res) => {++ const o = (await offerEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod);++ const p = (await productEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));});-- router.get('/sitemaps/offers.xml', (_req, res) => xml(res, seo.sitemap(offerEntries()).files[0]));-- router.get('/sitemaps/products.xml', (_req, res) => xml(res, seo.sitemap(productEntries()).files[0]));++ router.get('/sitemaps/offers.xml', async (_req, res) => xml(res, seo.sitemap(await offerEntries()).files[0]));++ router.get('/sitemaps/products.xml', async (_req, res) => xml(res, seo.sitemap(await productEntries()).files[0]));return router;}@@@ -11,17 +11,16 @@*/const express = require('express');const { http } = require('openvibe-contracts');--const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');++const { verifyDeliveryV2, createPgInbox } = require('openvibe-sdk/events');const CONSUMER = 'deals-sources';const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);function createInternalRoutes({ config, store, importer }) {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: () => true, limit: '256kb' }), (req, res) => {++ router.post('/internal/events', express.raw({ type: () => true, limit: '256kb' }), async (req, res, next) => {const secrets = config.events.webhookSecrets;if (!secrets.length) return http.sendProblem(res, 503, 'deals.webhook_disabled', { detail: 'DEALS_EVENTS_SECRET is not set', ctx: req.ov });const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);@@@ -33,12 +32,15 @@ function createInternalRoutes({ config, store, importer }) {if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {return http.sendProblem(res, 400, 'deals.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });}-- const r = inbox.once(CONSUMER, event.event_id, () => {-- if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';-- const category = event.payload && event.payload.category;-- if (category !== 'deals') return 'ignored';-- return 'import_scheduled';-- });++ let r;++ try {++ r = await inbox.once(CONSUMER, event.event_id, async () => {++ if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';++ const category = event.payload && event.payload.category;++ if (category !== 'deals') return 'ignored';++ return 'import_scheduled';++ });++ } catch (err) { return next(err); }if (!r.duplicate && r.result === 'import_scheduled') importer.kick();res.status(200).json({ event_id: event.event_id, duplicate: Boolean(r.duplicate), outcome: r.duplicate ? null : r.result });});@@@ -105,14 +105,14 @@ function createPages(ctx) {// ── lists ───────────────────────────────────────────────-- function listPage(req, res, kind) {++ async function listPage(req, res, kind) {const page = pageNumber(req);const path = kind === 'hot' ? '/' : '/new';-- const total = listings.countActive();++ const total = await listings.countActive();const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });if (pager.outOfRange && total) return notFound(req, res);-- const rows = kind === 'hot' ? listings.hot(PER_PAGE, pager.offset) : listings.newest(PER_PAGE, pager.offset);-- const list = rows.map((o) => publication.offerView(o));++ const rows = kind === 'hot' ? await listings.hot(PER_PAGE, pager.offset) : await listings.newest(PER_PAGE, pager.offset);++ const list = await Promise.all(rows.map(async (o) => await publication.offerView(o)));const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });send(req, res, 200, {title: kind === 'hot' ? 'Hot deals' : 'New deals',@@@ -132,14 +132,14 @@ function createPages(ctx) {}, { cacheable: true });}-- router.get('/', wrap(async (req, res) => listPage(req, res, 'hot')));++ router.get('/', wrap(async (req, res) => await listPage(req, res, 'hot')));// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.router.get('/updates', (req, res) => send(req, res, 200, {title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,body: frame.updatesBody({ service: 'deals', siteName: 'OpenVibe.Deals' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}, { cacheable: true }));-- router.get('/new', wrap(async (req, res) => listPage(req, res, 'new')));++ router.get('/new', wrap(async (req, res) => await listPage(req, res, 'new')));// ── offers ──────────────────────────────────────────────@@@ -157,7 +157,7 @@ function createPages(ctx) {}async function renderOffer(req, res, offer, { json = false } = {}) {-- const v = publication.offerView(offer);++ const v = await publication.offerView(offer);const r = v.root;const dto = publication.offerDto(v);if (r.status === 'disabled') {@@@ -168,7 +168,7 @@ function createPages(ctx) {const c = r.status === 'disabled' ? { state: 'off' } : await readComments(r, r.title, req);const mergedComments = [];for (const m of v.members.slice(0, 3)) {-- const tid = community.enabled ? community.knownThread(m) : null;++ const tid = community.enabled ? await community.knownThread(m) : null;if (!tid) continue;try { const data = await community.readThread(tid, { ctx: req.ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }}@@@ -182,21 +182,21 @@ function createPages(ctx) {type: 'website',jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],body: views.offerPage({-- v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? votes.mine(req.viewer.subject, r) : 0,++ v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? await votes.mine(req.viewer.subject, r) : 0,comments: c, mergedComments, canEdit: access.canEdit(req.viewer, r), isMod,-- log: isMod ? offers.moderationLog(r) : null, flags: isMod ? flags.forOffer(v.ids).map(flags.dto) : null, urls,++ log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,notice: req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null,}),}, { cacheable: true });}-- function offerFor(req, res) {++ async function offerFor(req, res) {const slug = String(req.params.slug || '');const json = slug.endsWith('.json');-- const offer = reads.find(json ? slug.slice(0, -5) : slug);++ const offer = await reads.find(json ? slug.slice(0, -5) : slug);if (!offer) { notFound(req, res); return null; }if (offer.merged_into) {-- const root = reads.root(offer);++ const root = await reads.root(offer);res.set('Cache-Control', 'public, max-age=300');res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);return null;@@@ -205,38 +205,38 @@ function createPages(ctx) {}router.get('/d/:slug', wrap(async (req, res) => {-- const f = offerFor(req, res);++ const f = await offerFor(req, res);if (f) await renderOffer(req, res, f.offer, { json: f.json });}));const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;const ip = (req) => req.ip || null;-- router.post('/d/:slug/vote', B('deals.vote'), form, wrap(async (req, res) => act(req, res, back(req), () => {++ router.post('/d/:slug/vote', B('deals.vote'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {const value = { up: 1, down: -1, remove: 0 }[String(req.body.value || '')];if (value === undefined) throw new ApiError(422, 'request.invalid', 'Choose hot, cold or remove');-- if (value === 0) votes.remove(req.viewer, req.params.slug, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });-- else votes.set(req.viewer, req.params.slug, value, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });++ if (value === 0) await votes.remove(req.viewer, req.params.slug, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });++ else await votes.set(req.viewer, req.params.slug, value, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });return `${back(req)}?done=vote`;})));-- router.post('/d/:slug/observe', B('deals.offer.observe'), form, wrap(async (req, res) => act(req, res, back(req), () => {-- offers.observe(req.viewer, req.params.slug, req.body, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });++ router.post('/d/:slug/observe', B('deals.offer.observe'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {++ await offers.observe(req.viewer, req.params.slug, req.body, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });return `${back(req)}?done=observe#history`;})));-- router.post('/d/:slug/flag', B('deals.flag.create'), form, wrap(async (req, res) => act(req, res, back(req), () => {-- flags.report(req.viewer, req.params.slug, req.body, { ip: ip(req) });++ router.post('/d/:slug/flag', B('deals.flag.create'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {++ await flags.report(req.viewer, req.params.slug, req.body, { ip: ip(req) });return `${back(req)}?done=flag`;})));-- router.post('/d/:slug/expire', B('deals.offer.update'), form, wrap(async (req, res) => act(req, res, back(req), () => {-- offers.expire(req.viewer, req.params.slug, {}, { traceparent: req.ov && req.ov.traceparent });++ router.post('/d/:slug/expire', B('deals.offer.update'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {++ await offers.expire(req.viewer, req.params.slug, {}, { traceparent: req.ov && req.ov.traceparent });return `${back(req)}?done=expire`;})));-- router.post('/d/:slug/comments', B('deals.comment.create'), form, wrap(async (req, res) => act(req, res, back(req), async () => {-- const root = reads.root(reads.mustFind(req.params.slug));++ router.post('/d/:slug/comments', B('deals.comment.create'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {++ const root = await reads.root(await reads.mustFind(req.params.slug));if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed');const message = String(req.body.message || '').trim().slice(0, 4000);if (!message) return `${back(req)}#comments`;@@@ -255,9 +255,9 @@ function createPages(ctx) {router.get('/p/:slug', wrap(async (req, res) => {const slug = String(req.params.slug || '');const json = slug.endsWith('.json');-- const product = catalog.productBySlug(json ? slug.slice(0, -5) : slug);++ const product = await catalog.productBySlug(json ? slug.slice(0, -5) : slug);if (!product) return notFound(req, res);-- const pv = publication.productView(product);++ const pv = await publication.productView(product);if (json) {return sendJson(req, res, 200, {product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },@@@ -276,13 +276,13 @@ function createPages(ctx) {}));router.get('/s/:domain', wrap(async (req, res) => {-- const st = catalog.storeByDomain(req.params.domain);++ const st = await catalog.storeByDomain(req.params.domain);if (!st) return notFound(req, res);const path = publication.storePath(st);-- const total = listings.countStore(st.id);++ const total = await listings.countStore(st.id);const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });if (pager.outOfRange && total) return notFound(req, res);-- const list = listings.byStore(st.id, PER_PAGE, pager.offset).map((o) => publication.offerView(o));++ const list = await Promise.all((await listings.byStore(st.id, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });send(req, res, 200, {title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,@@@ -296,12 +296,12 @@ function createPages(ctx) {router.get('/search', wrap(async (req, res) => {const q = String(req.query.q || '').slice(0, 200);const page = pageNumber(req);-- const r = q ? listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };++ const r = q ? await listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };const pager = q ? ssr.paginate({ page, perPage: PER_PAGE, total: r.total, href: (p) => `/search?q=${encodeURIComponent(q)}&page=${p}` }) : null;send(req, res, 200, {title: q ? `Search: ${q}` : 'Search deals',decision: pageDecision('/search', { indexable: false }),-- body: views.searchPage({ q, results: r.rows.map((o) => publication.offerView(o)), pager, urls, csrf: csrf(req), signedIn: signedIn(req) }),++ body: views.searchPage({ q, results: await Promise.all(r.rows.map(async (o) => await publication.offerView(o))), pager, urls, csrf: csrf(req), signedIn: signedIn(req) }),});}));@@@ -325,7 +325,7 @@ function createPages(ctx) {product: b.product_name || b.product_gtin ? { name: b.product_name, gtin: b.product_gtin } : null,};try {-- const { offer } = offers.submit(req.viewer, input, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });++ const { offer } = await offers.submit(req.viewer, input, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });res.redirect(303, publication.offerPath(offer));} catch (err) {if (!(err instanceof ApiError)) throw err;@@@ -336,30 +336,30 @@ function createPages(ctx) {// ── watches ─────────────────────────────────────────────-- function watchesPage(req, res, status = 200, { error, notice } = {}) {-- const list = watches.list(req.viewer.subject).map(watches.dto);++ async function watchesPage(req, res, status = 200, { error, notice } = {}) {++ const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls }) });}router.get('/watches', wrap(async (req, res) => {if (!signedIn(req)) return send(req, res, 200, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.message({ heading: 'Watches', text: 'Sign in to watch keywords, products and prices, and to save searches.', action: { href: '/auth/login?next=%2Fwatches', label: 'Sign in' } }) });-- watchesPage(req, res);++ await watchesPage(req, res);}));router.post('/watches', B('deals.watch'), form, wrap(async (req, res) => {if (!formGuard(req, res, '/watches')) return;try {const b = req.body || {};-- watches.create(req.viewer.subject, { kind: b.kind, query: b.query, product: b.product || null, max_price: b.max_price, currency: b.currency, label: b.label });++ await watches.create(req.viewer.subject, { kind: b.kind, query: b.query, product: b.product || null, max_price: b.max_price, currency: b.currency, label: b.label });res.redirect(303, '/watches');} catch (err) {if (!(err instanceof ApiError)) throw err;-- watchesPage(req, res, err.status, { error: err.message });++ await watchesPage(req, res, err.status, { error: err.message });}}));-- router.post('/watches/:id/delete', B('deals.watch'), form, wrap(async (req, res) => act(req, res, '/watches', () => {-- watches.remove(req.viewer.subject, req.params.id);++ router.post('/watches/:id/delete', B('deals.watch'), form, wrap(async (req, res) => await act(req, res, '/watches', async () => {++ await watches.remove(req.viewer.subject, req.params.id);return '/watches';})));@@@ -373,62 +373,62 @@ function createPages(ctx) {router.get('/mod', wrap(async (req, res) => {if (!modGuard(req, res)) return;-- const dups = listings.possibleDuplicates().map((d) => ({ a: reads.get(d.a), b: reads.get(d.b) }));++ const dups = await Promise.all((await listings.possibleDuplicates()).map(async (d) => ({ a: await reads.get(d.a), b: await reads.get(d.b) })));send(req, res, 200, {title: 'Moderation', decision: pageDecision('/mod', { indexable: false }),-- body: views.modPage({ csrf: csrf(req), flags: flags.list({ status: 'open' }).map(flags.dto), pending: listings.pendingReview(), duplicates: dups, urls }),++ body: views.modPage({ csrf: csrf(req), flags: await Promise.all((await flags.list({ status: 'open' })).map(flags.dto)), pending: await listings.pendingReview(), duplicates: dups, urls }),});}));router.post('/mod/offers/:slug/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {if (!modGuard(req, res)) return;const tp = { traceparent: req.ov && req.ov.traceparent };-- await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, () => {++ await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, async () => {const b = req.body || {};switch (req.params.action) {-- case 'merge': { const out = offers.merge(req.viewer, req.params.slug, b.into, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }-- case 'unmerge': { const out = offers.unmerge(req.viewer, req.params.slug, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }-- case 'disable': offers.disable(req.viewer, req.params.slug, { reason: b.reason }, tp); return '/mod';-- case 'enable': offers.enable(req.viewer, req.params.slug, { reason: b.reason }, tp); break;-- case 'review': offers.review(req.viewer, req.params.slug, { note: b.note }, tp); break;-- case 'expire': offers.expire(req.viewer, req.params.slug, { reason: b.reason }, tp); break;++ case 'merge': { const out = await offers.merge(req.viewer, req.params.slug, b.into, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }++ case 'unmerge': { const out = await offers.unmerge(req.viewer, req.params.slug, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }++ case 'disable': await offers.disable(req.viewer, req.params.slug, { reason: b.reason }, tp); return '/mod';++ case 'enable': await offers.enable(req.viewer, req.params.slug, { reason: b.reason }, tp); break;++ case 'review': await offers.review(req.viewer, req.params.slug, { note: b.note }, tp); break;++ case 'expire': await offers.expire(req.viewer, req.params.slug, { reason: b.reason }, tp); break;default: throw new ApiError(404, 'route.not_found', 'No such action');}-- return `/d/${encodeURIComponent(reads.root(reads.mustFind(req.params.slug)).slug)}?done=moderated`;++ return `/d/${encodeURIComponent((await reads.root(await reads.mustFind(req.params.slug))).slug)}?done=moderated`;});}));router.post('/mod/flags/:id/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {if (!modGuard(req, res)) return;-- await act(req, res, '/mod', () => {++ await act(req, res, '/mod', async () => {const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];if (!status) throw new ApiError(404, 'route.not_found', 'No such action');-- flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution }, { traceparent: req.ov && req.ov.traceparent });++ await flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution }, { traceparent: req.ov && req.ov.traceparent });return '/mod';});}));// ── feeds (never viewer-dependent) ──────────────────────-- function feedItems() {-- return listings.newest(50, 0).map((o) => {-- const v = publication.offerView(o);++ async function feedItems() {++ return Promise.all((await listings.newest(50, 0)).map(async (o) => {++ const v = await publication.offerView(o);const price = `${views.priceText(v.latest)}${v.latest ? ` as of ${iso(v.latest.observed_at)}` : ''}`;return {id: `deals:offer:${o.id}`, url: publication.abs(publication.offerPath(o)), title: o.title,summary: `${price}${v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${o.description ? ` ${o.description.slice(0, 300)}` : ''}`,published: o.created_at, updated: Math.max(o.updated_at, v.latest ? v.latest.observed_at : 0), decision: v.decision,};-- });++ }));}const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });-- router.get('/feed.xml', (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, feedItems())));-- router.get('/atom.xml', (_req, res) => {-- const items = feedItems();++ router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));++ router.get('/atom.xml', async (_req, res) => {++ const items = await feedItems();res.type('application/atom+xml').set('Cache-Control', 'public, max-age=300').send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));});-- router.get('/feed.json', (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, feedItems()))));++ router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));return { router, notFound, messagePage };}@@@ -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(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.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('[Deals] failed to start:', err); process.exit(1); });@@@ -16,7 +16,7 @@const { createReadiness } = require('openvibe-shared/ready');const { CHARTER_TABLES } = require('./db');--function createDealsReadiness({ store, auth, outbox, importer, worker, limits, release = null }) {++function createDealsReadiness({ store, auth, outbox, importer, worker, limits, valkey = null, release = null }) {const { db } = store;return createReadiness({service: 'deals',@@@ -24,12 +24,16 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, rchecks: [{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: () => {@@@ -40,8 +44,8 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r},{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 } };@@@ -49,8 +53,8 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r},{name: 'sources_import', required: false,-- check: () => {-- const s = importer.status();++ check: async () => {++ const s = await importer.status();if (!s.enabled) return 'import off (DEALS_IMPORT=off, or OV_OAUTH_CLIENT_SECRET / OV_SOURCES_INTERNAL_URL unset)';if (s.last_error) return `last pull failed: ${s.last_error}`;return { ok: true, detail: { cursor: s.cursor, last_run_at: s.last_run_at } };@@@ -24,17 +24,17 @@ function createWorker({ config, store, offers, hotness, indexing, listings, imporunning = true;const summary = {};try {-- summary.expired = offers.expireDue();-- summary.snapshots = hotness.tick({ windowDays: config.worker.hotWindowDays });++ summary.expired = await offers.expireDue();++ summary.snapshots = await hotness.tick({ windowDays: config.worker.hotWindowDays });let reindexed = 0;-- for (const o of listings.indexable()) {-- store.tx(() => { if (indexing.indexOffer(o)) reindexed++; });++ for (const o of await listings.indexable()) {++ await store.tx(async () => { if (await indexing.indexOffer(o)) reindexed++; });}-- for (const p of listings.products()) store.tx(() => { if (indexing.indexProduct(p)) reindexed++; });++ for (const p of await listings.products()) await store.tx(async () => { if (await indexing.indexProduct(p)) reindexed++; });summary.reindexed = reindexed;summary.import = await importer.pull();summary.refresh = await importer.refresh();-- summary.pruned = limits.prune();++ summary.pruned = await limits.prune();lastError = null;} catch (err) {lastError = err.message;@@@ -28,7 +28,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');assert.strictEqual(r.status, 429);assert.strictEqual(r.json().code, 'vote.rate_limited');assert.ok(Number(r.headers.get('retry-after')) > 0);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject).n, 4);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject)).n, 4);t.clock.advance(HOUR + 1000);assert.strictEqual((await vote(u, offers[4], 1, '198.51.100.9')).status, 200);});@@@ -47,12 +47,12 @@ const { boot, check, done, HOUR } = require('./helpers/boot');const codes = [];for (let i = 0; i < 6; i++) codes.push((await vote(t.network.addUser(`ipuser${i}`), offers[6], 1, ip)).status);assert.deepStrictEqual(codes, [200, 200, 200, 200, 200, 429]);-- const dump = JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM deal_votes').all()) + JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM rate_events').all()) + JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM deal_flags').all());++ const dump = JSON.stringify(await t.ctx.store.db.prepare('SELECT * FROM deal_votes').all()) + JSON.stringify(await t.ctx.store.db.prepare('SELECT * FROM rate_events').all()) + JSON.stringify(await t.ctx.store.db.prepare('SELECT * FROM deal_flags').all());assert.ok(!dump.includes(ip), 'no raw IP in votes, rate windows or flags');});await check('five voters from one IP hash on one offer raise ONE open shared_ip vote-ring flag', async () => {-- const flags = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'shared_ip'").all();++ const flags = await t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'shared_ip'").all();assert.strictEqual(flags.length, 1);assert.strictEqual(flags[0].status, 'open');assert.strictEqual(flags[0].offer_id, offers[6].id);@@@ -79,7 +79,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');t.clock.advance(60 * 1000);assert.strictEqual((await vote(b, offers[i], 1, `10.1.${i}.1`)).status, 200);}-- const flags = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'covote' AND status = 'open'").all();++ const flags = await t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'covote' AND status = 'open'").all();assert.strictEqual(flags.length, 1);const d = JSON.parse(flags[0].details);assert.deepStrictEqual(d.voters, [a.subject, b.subject].sort());@@@ -105,19 +105,19 @@ const { boot, check, done, HOUR } = require('./helpers/boot');});await check('a moderator resolves a flag (audited); flags never change votes by themselves', async () => {-- const f = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE reason = 'shared_ip'").get();-- const votesBefore = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n;++ const f = await t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE reason = 'shared_ip'").get();++ const votesBefore = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get()).n;const r = await t.get(`/mod/flags/${f.id}/resolve`, { as: t.mod, form: { resolution: 'checked' } });assert.strictEqual(r.status, 303);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT status FROM deal_flags WHERE id = ?').get(f.id).status, 'resolved');-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n, votesBefore);-- assert.ok(t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'flag.resolve'").get());++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT status FROM deal_flags WHERE id = ?').get(f.id)).status, 'resolved');++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get()).n, votesBefore);++ assert.ok(await t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'flag.resolve'").get());});await check('a person\'s repeated report updates their open flag instead of adding another', async () => {const u = t.network.addUser('reporter');for (let i = 0; i < 3; i++) assert.strictEqual((await t.get(`/d/${offers[1].slug}/flag`, { as: u, form: { kind: 'expired', reason: `try ${i}` } })).status, 303);-- const rows = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE reporter = ?').all(u.subject);++ const rows = await t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE reporter = ?').all(u.subject);assert.strictEqual(rows.length, 1);assert.strictEqual(rows[0].reason, 'try 2');});@@@ -134,7 +134,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');const u = t.network.addUser('csrf');const r = await t.get(`/d/${offers[3].slug}/vote`, { as: u, form: { value: 'up', csrf: 'forged' } });assert.strictEqual(r.status, 403);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject).n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject)).n, 0);});await t.close();@@@ -54,16 +54,16 @@ const { actor, serviceItself } = require('../server/http/actor-limits');await check('a write has its own budget (30 watch changes a minute), shared by the API and the form; nothing stored past it', async () => {clock = Date.UTC(2026, 8, 27, 12, 5, 0);-- const stored = (u) => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_watches WHERE subject = ?').get(u.subject).n;++ const stored = async (u) => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_watches WHERE subject = ?').get(u.subject)).n;for (let i = 0; i < 30; i++) {const r = await t.get('/api/v1/watches', { as: ann, json: { kind: 'keyword', query: `thing ${i}` } });assert.strictEqual(r.status, 201, `watch ${i + 1}: ${r.text}`);}-- const before = stored(ann);++ const before = await stored(ann);const r = await t.get('/watches', { as: ann, form: { kind: 'keyword', query: 'one more' } });assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');assert.ok(r.json().detail.includes('deals.watch'), r.json().detail);-- assert.strictEqual(stored(ann), before, 'nothing stored');++ assert.strictEqual(await stored(ann), before, 'nothing stored');assert.strictEqual((await t.get('/api/v1/watches', { as: ben, json: { kind: 'keyword', query: 'mine' } })).status, 201, 'another person still writes');});@@@ -134,7 +134,7 @@ const { boot, check, done } = require('./helpers/boot');});await check('every event validates as events.event-envelope@1; every Search document as search.index-document@1', async () => {-- const all = t.events();++ const all = await t.events();assert.ok(all.length > 10);const types = new Set();for (const e of all) {@@@ -60,16 +60,16 @@ const { sourceItem } = require('./helpers/mocks');});await check('the worker re-indexes a price that went stale: one new Search document (noindex), then nothing', async () => {-- const before = t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id);++ const before = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === offer.id);await t.ctx.worker.tick();-- const after = t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id);++ const after = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === offer.id);assert.strictEqual(after.length, before.length + 1);const doc = after[after.length - 1].payload;assert.strictEqual(doc.facets.freshness, 'stale');assert.strictEqual(doc.indexability.decision, 'noindex');assert.ok(doc.revision > before[before.length - 1].payload.revision);await t.ctx.worker.tick();-- assert.strictEqual(t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id).length, after.length);++ assert.strictEqual((await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === offer.id).length, after.length);});await check('a new observation makes it fresh again, and the history keeps both, newest first', async () => {@@@ -88,7 +88,7 @@ const { sourceItem } = require('./helpers/mocks');await check('an imported observation is dated by when the source saw it: old retrieval → stale from the start', async () => {t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ1', title: 'Old TV offer', url: 'https://tv.example/old', fields: { price: '300', currency: 'EUR' }, retrievedAt: t.clock.now() - 72 * HOUR }));await t.ctx.importer.pull();-- const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://tv.example/old'").get();++ const o = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://tv.example/old'").get();const json = await t.offerJson(o.slug);assert.strictEqual(json.latest_observation.observed_at, new Date(t.clock.now() - 72 * HOUR).toISOString());assert.strictEqual(json.freshness.state, 'stale');@@@ -44,11 +44,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 DEALS_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}`;@@@ -57,7 +60,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;}@@@ -81,8 +84,8 @@ async function boot(opts = {}) {}/** Rows of event_outbox as parsed envelopes. */-- 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)));}@@@ -91,7 +94,7 @@ async function boot(opts = {}) {const r = await get('/submit', { as: user, form: fields, ip });if (r.status !== 303) throw new Error(`submit answered ${r.status}: ${r.text.slice(0, 400)}`);const slug = decodeURIComponent(r.headers.get('location').replace(/^\/d\//, '').replace(/\?.*$/, ''));-- return t.ctx.reads.find(slug);++ return await t.ctx.reads.find(slug);}function offerJson(slug) { return get(`/d/${slug}.json`).then((r) => r.json().offer); }@@@ -100,7 +103,7 @@ async function boot(opts = {}) {network, community, sources, clock, dbPath, mod, get, events, submit, offerJson, HOUR,csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.DEALS_FORM_SECRET }, user),async restart() { await stop(); await start(); },-- async close() { await stop(); await network.close(); await community.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },++ async close() { await stop(); await testdb.close(); await network.close(); await community.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },};await start();return t;nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * DEALS_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.DEALS_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'deals', max });++++module.exports = { testDb, pgAvailable };@@@ -28,47 +28,47 @@ const { formula } = require('../server/domain/hotness');const plan = [[users[0], 'up'], [users[1], 'up'], [users[2], 'down'], [users[3], 'up']];for (const [u, v] of plan) await t.get(`/d/${X.slug}/vote`, { as: u, form: { value: v } });for (const [u, v] of [...plan].reverse()) await t.get(`/d/${Y.slug}/vote`, { as: u, form: { value: v } });-- const sx = t.ctx.reads.lastSnapshot(X.id);-- const sy = t.ctx.reads.lastSnapshot(Y.id);++ const sx = await t.ctx.reads.lastSnapshot(X.id);++ const sy = await t.ctx.reads.lastSnapshot(Y.id);for (const k of ['formula', 'up_count', 'down_count', 'up_weight', 'down_weight', 'score', 'hot']) assert.strictEqual(sx[k], sy[k], k);assert.strictEqual(sx.score, 2);assert.strictEqual(sx.hot, formula({ upWeight: 3, downWeight: 1, firstSeenAt: X.created_at, t: sx.computed_at }).hot);});await check('every stored snapshot recomputes to itself (GET /api/v1/offers/:id/hotness)', async () => {-- const rows = t.ctx.store.db.prepare('SELECT * FROM deal_hotness_snapshots').all();++ const rows = await t.ctx.store.db.prepare('SELECT * FROM deal_hotness_snapshots').all();assert.ok(rows.length >= 6);for (const s of rows) assert.strictEqual(t.ctx.hotness.recompute(s).hot, s.hot, `snapshot ${s.id}`);-- const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();++ const X = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();const r = (await t.get(`/api/v1/offers/${X.slug}/hotness`)).json();assert.strictEqual(r.recomputed.hot, r.snapshot.hot);assert.match(r.formula, /hot@1/);});await check('clients cannot set weight, score or hotness: only value is read', async () => {-- const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();-- const before = t.ctx.reads.lastSnapshot(X.id);++ const X = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();++ const before = await t.ctx.reads.lastSnapshot(X.id);const r = await t.get(`/api/v1/offers/${X.id}/vote`, { method: 'PUT', as: users[4], json: { value: 1, weight: 100, score: 999, hot: 999 } });assert.strictEqual(r.status, 200, r.text);-- const row = t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ? AND offer_id = ?').get(users[4].subject, X.id);++ const row = await t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ? AND offer_id = ?').get(users[4].subject, X.id);assert.strictEqual(row.weight, 1);-- const after = t.ctx.reads.lastSnapshot(X.id);++ const after = await t.ctx.reads.lastSnapshot(X.id);assert.strictEqual(after.up_weight, before.up_weight + 1);});await check('accounts new to OpenVibe count 0.25 until there is evidence they are a week old', async () => {-- const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();++ const X = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();const fresh = t.network.addUser('newbie', { mintedAt: t.clock.now() - 2 * 24 * HOUR });await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'up' } });-- const row = t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject);++ const row = await t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject);assert.strictEqual(row.weight, 0.25);-- const snap = t.ctx.reads.lastSnapshot(X.id);++ const snap = await t.ctx.reads.lastSnapshot(X.id);assert.strictEqual(snap.up_count, 5);assert.strictEqual(snap.up_weight, 4.25);// Six days later the subject id proves ≥ 7 days: a new vote carries weight 1.t.clock.advance(6 * 24 * HOUR);await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'down' } });-- assert.strictEqual(t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject).weight, 1);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject)).weight, 1);});await check('the worker snapshots every active offer at one shared t; the hot list follows those snapshots', async () => {@@@ -76,7 +76,7 @@ const { formula } = require('../server/domain/hotness');for (const u of users.slice(0, 5)) await t.get(`/d/${Z.slug}/vote`, { as: u, form: { value: 'up' } });t.clock.advance(HOUR);await t.ctx.worker.tick();-- const ticks = t.ctx.store.db.prepare("SELECT offer_id, computed_at, hot FROM deal_hotness_snapshots WHERE reason = 'tick'").all();++ const ticks = await t.ctx.store.db.prepare("SELECT offer_id, computed_at, hot FROM deal_hotness_snapshots WHERE reason = 'tick'").all();assert.strictEqual(new Set(ticks.map((x) => x.computed_at)).size, 1);assert.strictEqual(ticks.length, 3);const list = await t.get('/api/v1/offers?sort=hot');@@@ -11,8 +11,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');(async () => {const t = await boot();const alice = t.network.addUser('alice');-- const byUrl = (u) => t.ctx.store.db.prepare('SELECT * FROM deal_offers WHERE url = ?').get(u);-- const obsOf = (id) => t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at, rowid').all(id);++ const byUrl = async (u) => await t.ctx.store.db.prepare('SELECT * FROM deal_offers WHERE url = ?').get(u);++ const obsOf = async (id) => await t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at, seq').all(id);const T0 = t.clock.now();await check('offer, product and article items become offers; values exactly as stated; observed_at = retrieved_at', async () => {@@@ -28,20 +28,20 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');assert.deepStrictEqual(s.outcomes, { created: 4 }, JSON.stringify(s));assert.strictEqual(s.items, 3);-- const esp = byUrl('https://coffee.example/espresso');++ const esp = await byUrl('https://coffee.example/espresso');assert.strictEqual(esp.origin, 'import');assert.strictEqual(esp.review_state, 'pending');assert.strictEqual(esp.expires_at, Date.parse('2026-10-01'));-- const o = obsOf(esp.id)[0];++ const o = (await obsOf(esp.id))[0];assert.deepStrictEqual([o.price, o.currency, o.availability, o.condition, o.observed_at, o.origin], ['249.00', 'EUR', 'in_stock', 'refurbished', T0 - HOUR, 'import']);-- assert.strictEqual(t.ctx.catalog.store(esp.store_id).name, 'Coffee Shop');++ assert.strictEqual((await t.ctx.catalog.store(esp.store_id)).name, 'Coffee Shop');-- const k1 = byUrl('https://kettles.example/acme-2000');-- const k2 = byUrl('https://market.example/acme-2000');++ const k1 = await byUrl('https://kettles.example/acme-2000');++ const k2 = await byUrl('https://market.example/acme-2000');assert.ok(k1 && k2 && k1.product_id && k1.product_id === k2.product_id, 'one product, two offers (tracking parameter stripped)');-- assert.strictEqual(obsOf(k2.id)[0].price, null);-- assert.strictEqual(obsOf(k2.id)[0].availability, 'out_of_stock');-- const product = t.ctx.catalog.product(k1.product_id);++ assert.strictEqual((await obsOf(k2.id))[0].price, null);++ assert.strictEqual((await obsOf(k2.id))[0].availability, 'out_of_stock');++ const product = await t.ctx.catalog.product(k1.product_id);assert.strictEqual(product.brand, 'Acme', 'the brand the source stated');const p = (await t.get(`/p/${product.slug}.json`)).json();assert.strictEqual(p.offers.length, 2);@@@ -49,7 +49,7 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');});await check('imported text is noindex until a person reviews it; review is recorded and re-indexes', async () => {-- const esp = byUrl('https://coffee.example/espresso');++ const esp = await byUrl('https://coffee.example/espresso');const page = await t.get(`/d/${esp.slug}`);assert.strictEqual(robotsOf(page.text), 'noindex, follow');assert.match(page.text, /imported from a source and has not been reviewed/);@@@ -61,20 +61,20 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');const after = await t.offerJson(esp.slug);assert.strictEqual(after.review_state, 'reviewed');assert.strictEqual(after.indexability.indexable, true);-- const doc = t.events('deals.index_document.upserted').filter((e) => e.payload.id === esp.id).pop().payload;++ const doc = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === esp.id).pop().payload;assert.strictEqual(doc.indexability.decision, 'index');assert.strictEqual(doc.authorship, 'imported');assert.deepStrictEqual(doc.provenance.map((x) => [x.service, x.type, x.id]), [['sources', 'item', 'itm_01K5AAAAAAAAAAAAAAAAAAAAA1']]);});await check('replaying the feed changes nothing (same revision, same retrieval)', async () => {-- const count = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get().n;-- const events = t.events().length;-- t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();++ const count = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get()).n;++ const events = (await t.events()).length;++ await t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();const s = await t.ctx.importer.pull();assert.deepStrictEqual(s.outcomes, { unchanged: 4 });-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get().n, count);-- assert.strictEqual(t.events().length, events);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get()).n, count);++ assert.strictEqual((await t.events()).length, events);});await check('a new revision is a new observation; the history keeps the old price with its time', async () => {@@@ -83,8 +83,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');fields: { price: '229.00', currency: 'EUR', availability: 'LimitedAvailability', seller: 'Coffee Shop' } }));const s = await t.ctx.importer.pull();assert.deepStrictEqual(s.outcomes, { updated: 1 });-- const esp = byUrl('https://coffee.example/espresso');-- assert.deepStrictEqual(obsOf(esp.id).map((o) => [o.price, o.source_revision]), [['249.00', 1], ['229.00', 2]]);++ const esp = await byUrl('https://coffee.example/espresso');++ assert.deepStrictEqual((await obsOf(esp.id)).map((o) => [o.price, o.source_revision]), [['249.00', 1], ['229.00', 2]]);assert.strictEqual((await t.offerJson(esp.slug)).latest_observation.price, '229.00');});@@@ -95,7 +95,7 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA4', sourceKey: 'other-feed', kind: 'article', title: 'Espresso deal', url: 'http://www.coffee.example/espresso', retrievedAt: t.clock.now() }));const s = await t.ctx.importer.pull();assert.deepStrictEqual(s.outcomes, { attached: 1 });-- const esp = byUrl('https://coffee.example/espresso');++ const esp = await byUrl('https://coffee.example/espresso');const json = await t.offerJson(esp.slug);assert.deepStrictEqual(json.sources.map((x) => x.key).sort(), ['other-feed', 'shop-jsonld']);});@@@ -106,8 +106,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');it.provenance = { ...it.provenance, retrieved_at: new Date(t.clock.now() - HOUR).toISOString() };const r = await t.ctx.importer.refresh();assert.ok(r.outcomes.refreshed >= 1, JSON.stringify(r));-- const garden = byUrl('https://news.example/garden');-- const obs = obsOf(garden.id);++ const garden = await byUrl('https://news.example/garden');++ const obs = await obsOf(garden.id);assert.strictEqual(obs.length, 2);assert.strictEqual(obs[1].origin, 'import_refresh');assert.strictEqual(obs[1].observed_at, t.clock.now() - HOUR);@@@ -117,11 +117,11 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');t.sources.put({ ...t.sources.items.find((i) => i.id === 'itm_01K5AAAAAAAAAAAAAAAAAAAAA3'), removed: { at: new Date(t.clock.now()).toISOString(), reason: 'licence withdrawn' } });const s = await t.ctx.importer.pull();assert.deepStrictEqual(s.outcomes, { removed: 1 });-- const garden = byUrl('https://news.example/garden');++ const garden = await byUrl('https://news.example/garden');assert.strictEqual(garden.status, 'disabled');assert.match(garden.disabled_reason, /licence withdrawn/);assert.strictEqual((await t.get(`/d/${garden.slug}`)).status, 410);-- assert.ok(t.events('deals.index_document.deleted').some((e) => e.payload.id === garden.id));++ assert.ok((await t.events('deals.index_document.deleted')).some((e) => e.payload.id === garden.id));assert.ok(!(await t.get('/feed.xml')).text.includes(garden.slug));});@@@ -141,12 +141,12 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');});await check('Sources down: the pull fails honestly (readiness says so), keeps its cursor, invents nothing', async () => {-- const cursor = t.ctx.importer.cursor();-- const offers = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n;++ const cursor = await t.ctx.importer.cursor();++ const offers = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get()).n;t.sources.setDown(true);await t.ctx.importer.pull();-- assert.strictEqual(t.ctx.importer.cursor(), cursor);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n, offers);++ assert.strictEqual(await t.ctx.importer.cursor(), cursor);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get()).n, offers);const ready = (await t.get('/api/ready')).json();assert.strictEqual(ready.checks.sources_import.status, 'fail');assert.match(ready.checks.sources_import.reason || JSON.stringify(ready.checks.sources_import), /Sources answered 503/);@@@ -31,7 +31,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');await check('only moderators merge', async () => {const r = await t.get(`/mod/offers/${B.slug}/merge`, { as: eve, form: { into: A.slug } });assert.strictEqual(r.status, 403);-- assert.strictEqual(t.ctx.reads.get(B.id).merged_into, null);++ assert.strictEqual((await t.ctx.reads.get(B.id)).merged_into, null);});await check('merge B into A: the voter who voted on both counts once, every observation and source is kept', async () => {@@@ -44,7 +44,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');assert.strictEqual(a.latest_observation.price, '389', 'the latest observation of the group');assert.strictEqual(a.sources.length, 3);assert.deepStrictEqual(a.merged_from.map((m) => m.slug), [B.slug]);-- const rows = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n;++ const rows = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get()).n;assert.strictEqual(rows, 4, 'no vote row was deleted, moved or copied');});@@@ -54,15 +54,15 @@ const { boot, check, done, HOUR } = require('./helpers/boot');assert.strictEqual(page.headers.get('location'), `/d/${A.slug}`);const json = await t.get(`/d/${B.slug}.json`);assert.strictEqual(json.headers.get('location'), `/d/${A.slug}.json`);-- const tomb = t.events('deals.index_document.deleted').filter((e) => e.payload.id === B.id);++ const tomb = (await t.events('deals.index_document.deleted')).filter((e) => e.payload.id === B.id);assert.strictEqual(tomb.length, 1);-- const upd = t.events('deals.offer.updated').filter((e) => e.payload.merged_offer_id === B.id);++ const upd = (await t.events('deals.offer.updated')).filter((e) => e.payload.merged_offer_id === B.id);assert.strictEqual(upd.length, 1);assert.strictEqual(upd[0].subject.id, A.id);});await check('the merge is audited with the tallies before and after', async () => {-- const log = t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'merge'").all();++ const log = await t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'merge'").all();assert.strictEqual(log.length, 1);assert.strictEqual(log[0].offer_id, B.id);assert.strictEqual(log[0].target_id, A.id);@@@ -76,13 +76,13 @@ const { boot, check, done, HOUR } = require('./helpers/boot');await check('a vote cast through the merged listing lands on the canonical offer; changing a vote counts once', async () => {t.clock.advance(60 * 1000);assert.strictEqual((await vote(v4, B.slug, 'up')).status, 303);-- const row = t.ctx.store.db.prepare('SELECT offer_id FROM deal_votes WHERE subject = ?').get(v4.subject);++ const row = await t.ctx.store.db.prepare('SELECT offer_id FROM deal_votes WHERE subject = ?').get(v4.subject);assert.strictEqual(row.offer_id, A.id);t.clock.advance(60 * 1000);await vote(v1, A.slug, 'down');const a = await t.offerJson(A.slug);assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 2], 'v2, v4 up; v1 (now down, once), v3 down');-- assert.strictEqual(t.ctx.votes.mine(v1.subject, t.ctx.reads.get(A.id)), -1);++ assert.strictEqual(await t.ctx.votes.mine(v1.subject, await t.ctx.reads.get(A.id)), -1);});await check('merging the canonical offer into its own duplicate is refused (no cycles)', async () => {@@@ -103,14 +103,14 @@ const { boot, check, done, HOUR } = require('./helpers/boot');assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 1], 'A: v2, v4 up, v1 down (cast on A)');assert.strictEqual(a.observations.length, 1);assert.strictEqual((await t.get(`/d/${B.slug}`)).status, 200);-- const log = t.ctx.store.db.prepare("SELECT action FROM moderation_log WHERE action IN ('merge','unmerge') ORDER BY id").all().map((x) => x.action);++ const log = (await t.ctx.store.db.prepare("SELECT action FROM moderation_log WHERE action IN ('merge','unmerge') ORDER BY id").all()).map((x) => x.action);assert.deepStrictEqual(log, ['merge', 'unmerge']);-- const up = t.events('deals.index_document.upserted').filter((e) => e.payload.id === B.id).pop();++ const up = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === B.id).pop();assert.ok(up, 'B is back in Search');});await check('hotness after merge and unmerge is computed from the group (snapshots record the reason)', async () => {-- const reasons = t.ctx.store.db.prepare('SELECT reason FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id').all(A.id).map((x) => x.reason);++ const reasons = (await t.ctx.store.db.prepare('SELECT reason FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id').all(A.id)).map((x) => x.reason);assert.ok(reasons.includes('merge') && reasons.includes('unmerge'));t.clock.advance(HOUR);});@@@ -34,13 +34,13 @@ const { sourceItem } = require('./helpers/mocks');const r = await t.get('/submit', { as: alice, form: { url: 'https://store.example/x', title: 'Currency test', price: '10' } });assert.strictEqual(r.status, 422);assert.match(r.text, /needs its currency/);-- assert.strictEqual(t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE url = 'https://store.example/x'").get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE url = 'https://store.example/x'").get()).n, 0);const bad = await t.get('/submit', { as: alice, form: { url: 'https://store.example/y', title: 'Symbol test', price: '$10', currency: 'USD' } });assert.strictEqual(bad.status, 422);});await check('the price comparison ranks fresh stated prices first; unknown and stale never sort as cheap', async () => {-- const product = t.ctx.catalog.product(noPrice.product_id);++ const product = await t.ctx.catalog.product(noPrice.product_id);// An older, cheaper offer that goes stale, then a fresh one with a stated price.const stale = await t.submit(alice, { url: 'https://cheap.example/lamp', title: 'Desk lamp at Cheap', price: '5.00', currency: 'USD', product_name: 'Acme desk lamp' });assert.strictEqual(stale.product_id, product.id, 'product resolved by its name alias');@@@ -66,7 +66,7 @@ const { sourceItem } = require('./helpers/mocks');await check('a price in an imported headline is not parsed into a price', async () => {t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ2', kind: 'article', title: 'Acme blender for $19 + free shipping', url: 'https://deals.example/blender', retrievedAt: t.clock.now() }));await t.ctx.importer.pull();-- const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/blender'").get();++ const o = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/blender'").get();const json = await t.offerJson(o.slug);assert.strictEqual(json.latest_observation.price, null);assert.strictEqual(json.latest_observation.currency, null);@@@ -75,7 +75,7 @@ const { sourceItem } = require('./helpers/mocks');await check('a malformed imported price stays null (never repaired)', async () => {t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ3', title: 'Weird price', url: 'https://deals.example/weird', fields: { price: 'call us', currency: 'USD', availability: 'SomethingElse' }, retrievedAt: t.clock.now() }));await t.ctx.importer.pull();-- const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/weird'").get();++ const o = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/weird'").get();const json = await t.offerJson(o.slug);assert.strictEqual(json.latest_observation.price, null);assert.strictEqual(json.latest_observation.availability, null, 'an unknown availability term is not guessed');@@@ -85,7 +85,7 @@ const { sourceItem } = require('./helpers/mocks');const feed = await t.get('/feed.json');const item = feed.json().items.find((i) => i.url.endsWith(noPrice.slug));assert.match(item.summary, /Price not stated/);-- const doc = t.events('deals.index_document.upserted').filter((e) => e.payload.id === noPrice.id).pop().payload;++ const doc = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === noPrice.id).pop().payload;assert.match(doc.body, /price not stated/);assert.strictEqual(doc.facets.currency, undefined);});@@@ -12,8 +12,8 @@ const { boot, check, done } = require('./helpers/boot');const t = await boot();const poster = t.network.addUser('poster');const reporter = t.network.addUser('reporter');-- const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()-- .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'deals.moderation.action');++ const moderation = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all())++ .map((r) => r.envelope).filter((e) => e.event_type === 'deals.moderation.action');const valid = (e) => {assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));const r = contracts.validate('deals.moderation.action@1', e.payload);@@@ -28,14 +28,14 @@ const { boot, check, done } = require('./helpers/boot');const o = await t.submit(poster, { url: 'https://s.example/own', title: 'My own offer', price: '5', currency: 'USD' });assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: poster, json: { title: 'My own offer, edited' } })).status, 200);assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/expire`, { as: poster, json: {} })).status, 200);-- assert.strictEqual(moderation().length, 0);++ assert.strictEqual((await moderation()).length, 0);});await check('a moderator disabling someone else\'s offer: exactly one valid event', async () => {const o = await t.submit(poster, { url: 'https://s.example/spam', title: 'Spammy offer', price: '1', currency: 'USD' });const r = await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Referral spam' } });assert.strictEqual(r.status, 200, r.text);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, 1);valid(ev[0]);assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `offer:${o.id}` });@@@ -44,14 +44,14 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(ev[0].payload.reason, 'Referral spam');assert.deepStrictEqual(ev[0].payload.details, { previous: 'active' });assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Again' } })).status, 200);-- assert.strictEqual(moderation().length, 1, 'already disabled: nothing changed, nothing reported');++ assert.strictEqual((await moderation()).length, 1, 'already disabled: nothing changed, nothing reported');});await check('a moderator editing someone else\'s offer: one offer.edited naming the fields, never the text', async () => {const o = await t.submit(poster, { url: 'https://s.example/typo', title: 'Offer with a tpyo', price: '2', currency: 'USD' });-- const n = moderation().length;++ const n = (await moderation()).length;assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: t.mod, json: { title: 'Offer with a typo' } })).status, 200);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, n + 1);valid(ev[n]);assert.strictEqual(ev[n].payload.action, 'offer.edited');@@@ -62,11 +62,11 @@ const { boot, check, done } = require('./helpers/boot');await check('a moderator dismissing a person\'s report: one flag.dismissed with the reporter as owner', async () => {const o = await t.submit(poster, { url: 'https://s.example/reported', title: 'Reported offer', price: '3', currency: 'USD' });assert.ok((await t.get(`/api/v1/offers/${o.id}/flags`, { as: reporter, json: { kind: 'expired', reason: 'Sold out' } })).status < 300);-- const f = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE offer_id = ?').get(o.id);-- const n = moderation().length;++ const f = await t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE offer_id = ?').get(o.id);++ const n = (await moderation()).length;const r = await t.get(`/api/v1/flags/${f.id}/resolve`, { as: t.mod, json: { status: 'dismissed', resolution: 'Still in stock' } });assert.strictEqual(r.status, 200, r.text);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, n + 1);valid(ev[n]);assert.strictEqual(ev[n].payload.action, 'flag.dismissed');@@@ -12,7 +12,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');let deal, timed;await check('the site starts empty: no seed deals, an honest empty page that is noindex', async () => {-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get()).n, 0);const r = await t.get('/');assert.strictEqual(r.status, 200);assert.match(r.text, /No deals have been posted yet/);@@@ -63,7 +63,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');assert.match(r.headers.get('cache-control'), /private, no-store/);const thread = [...t.community.threads.values()][0];assert.deepStrictEqual(thread.ref, { service: 'deals', type: 'offer', id: deal.id, label: 'Mini drone with camera' });-- const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('deal_offer_discussion_refs')").all().map((x) => x.name).join(',');++ const cols = (await t.ctx.store.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'deal_offer_discussion_refs' ORDER BY ordinal_position").all()).map((x) => x.name).join(',');assert.doesNotMatch(cols, /message|body|text/);t.community.setDown(true);const down = await t.get(`/d/${deal.slug}`);@@@ -73,7 +73,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');await check('product, store and search pages', async () => {const p = await t.submit(alice, { url: 'https://gadgets.example/drone-pro', title: 'Drone Pro', price: '199', currency: 'USD', product_name: 'SkyCam Drone Pro' });-- const prod = t.ctx.catalog.product(p.product_id);++ const prod = await t.ctx.catalog.product(p.product_id);const pr = await t.get(`/p/${prod.slug}`);assert.strictEqual(pr.status, 200);assert.match(pr.text, /Price comparison/);@@@ -97,7 +97,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');const page = await t.get(`/d/${deal.slug}`);assert.match(page.text, /This deal expired/);assert.strictEqual(robotsOf(page.text), 'noindex, follow');-- const e = t.events('deals.offer.expired');++ const e = await t.events('deals.offer.expired');assert.strictEqual(e.length, 1);assert.strictEqual(e[0].payload.reason, 'submitter');assert.ok(!(await t.get('/')).text.includes(deal.slug));@@@ -110,16 +110,16 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');assert.match((await t.get(`/d/${open.slug}`)).text, /End date: not stated/);t.clock.advance(3 * HOUR);await t.ctx.worker.tick();-- assert.strictEqual(t.ctx.reads.get(timed.id).status, 'expired');-- assert.strictEqual(t.ctx.reads.get(timed.id).expired_reason, 'stated_expiry');-- assert.strictEqual(t.ctx.reads.get(open.id).status, 'active');++ assert.strictEqual((await t.ctx.reads.get(timed.id)).status, 'expired');++ assert.strictEqual((await t.ctx.reads.get(timed.id)).expired_reason, 'stated_expiry');++ assert.strictEqual((await t.ctx.reads.get(open.id)).status, 'active');t.clock.advance(30 * 24 * HOUR);await t.ctx.worker.tick();-- assert.strictEqual(t.ctx.reads.get(open.id).status, 'active', 'stale, but never assumed expired');++ assert.strictEqual((await t.ctx.reads.get(open.id)).status, 'active', 'stale, but never assumed expired');});await check('moderators disable a deal: 410, out of feeds, sitemaps and Search; enable brings it back', async () => {-- const target = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Drone Pro'").get();++ const target = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Drone Pro'").get();await t.get(`/d/${target.slug}`);const thread = [...t.community.threads.values()].find((x) => x.ref.id === target.id);assert.ok(thread, 'the page resolved its Community thread');@@@ -136,13 +136,13 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');}assert.strictEqual((await t.get(`/api/v1/offers/${target.id}`, { as: t.mod })).status, 200, 'moderators still read it');assert.ok(!(await t.get('/feed.xml')).text.includes(target.slug));-- assert.ok(t.events('deals.index_document.deleted').some((e) => e.payload.id === target.id));++ assert.ok((await t.events('deals.index_document.deleted')).some((e) => e.payload.id === target.id));const en = await t.get(`/mod/offers/${target.slug}/enable`, { as: t.mod, form: {} });assert.strictEqual(en.status, 303);assert.strictEqual((await t.get(`/d/${target.slug}`)).status, 200);await new Promise((ok) => setTimeout(ok, 100));assert.strictEqual(thread.visibility, 'public');-- const log = t.ctx.store.db.prepare('SELECT action FROM moderation_log WHERE offer_id = ? ORDER BY id').all(target.id).map((x) => x.action);++ const log = (await t.ctx.store.db.prepare('SELECT action FROM moderation_log WHERE offer_id = ? ORDER BY id').all(target.id)).map((x) => x.action);assert.deepStrictEqual(log, ['disable', 'enable']);});@@@ -170,7 +170,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');assert.match(idx.text, /sitemaps\/offers.xml/);assert.match(idx.text, /sitemaps\/products.xml/);const offers = await t.get('/sitemaps/offers.xml');-- const fresh = t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get().slug;++ const fresh = (await t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get()).slug;assert.ok(offers.text.includes(fresh));assert.ok(!offers.text.includes(timed.slug), 'expired');assert.ok(!offers.text.includes(deal.slug), 'expired');@@@ -11,7 +11,7 @@ const { sourceItem } = require('./helpers/mocks');(async () => {const t = await boot();const [watcher, poster, other] = ['watcher', 'poster', 'other'].map((n) => t.network.addUser(n));-- const matched = (watchId) => t.events('deals.watch.matched').filter((e) => !watchId || e.payload.watch_id === watchId);++ const matched = async (watchId) => (await t.events('deals.watch.matched')).filter((e) => !watchId || e.payload.watch_id === watchId);const w = {};let offer;@@@ -24,7 +24,7 @@ const { sourceItem } = require('./helpers/mocks');const r = await t.get('/watches', { as: watcher, form });assert.strictEqual(r.status, 303, r.text.slice(0, 300));}-- const list = t.ctx.watches.list(watcher.subject);++ const list = await t.ctx.watches.list(watcher.subject);for (const x of list) w[x.kind === 'price_below' ? 'price' : x.kind === 'search' ? 'saved' : 'keyword'] = x.id;assert.strictEqual(list.length, 3);const page = await t.get('/watches', { as: watcher });@@@ -36,10 +36,10 @@ const { sourceItem } = require('./helpers/mocks');await check('a matching new offer notifies the keyword watch once; the price watch stays quiet above its limit', async () => {offer = await t.submit(poster, { url: 'https://audio.example/nc700', title: 'Noise cancelling headphones NC700', price: '49.99', currency: 'USD' });-- assert.strictEqual(matched(w.keyword).length, 1);-- assert.strictEqual(matched(w.price).length, 0);-- assert.strictEqual(matched(w.saved).length, 0);-- const e = matched(w.keyword)[0];++ assert.strictEqual((await matched(w.keyword)).length, 1);++ assert.strictEqual((await matched(w.price)).length, 0);++ assert.strictEqual((await matched(w.saved)).length, 0);++ const e = (await matched(w.keyword))[0];assert.strictEqual(e.payload.recipient, watcher.subject);assert.strictEqual(e.visibility, 'internal');assert.strictEqual(e.payload.observation.price, '49.99');@@@ -49,66 +49,66 @@ const { sourceItem } = require('./helpers/mocks');await check('a lower observation notifies the price watch; the keyword watch does not repeat for the same offer', async () => {await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35', currency: 'USD' } });-- assert.strictEqual(matched(w.price).length, 1);-- assert.strictEqual(matched(w.keyword).length, 1);++ assert.strictEqual((await matched(w.price)).length, 1);++ assert.strictEqual((await matched(w.keyword)).length, 1);});await check('re-running the matcher on the same observation emits nothing (one notification per observation)', async () => {-- const obs = t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at DESC, rowid DESC LIMIT 1').get(offer.id);-- const before = matched().length;-- for (let i = 0; i < 3; i++) t.ctx.store.tx(() => t.ctx.watches.onObservation(obs));-- assert.strictEqual(matched().length, before);-- const rows = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE observation_id = ?').get(obs.id).n;++ const obs = await t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at DESC, seq DESC LIMIT 1').get(offer.id);++ const before = (await matched()).length;++ for (let i = 0; i < 3; i++) await t.ctx.store.tx(async () => await t.ctx.watches.onObservation(obs));++ assert.strictEqual((await matched()).length, before);++ const rows = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE observation_id = ?').get(obs.id)).n;assert.strictEqual(rows, 1);// The primary key is the guarantee, not just the code path.-- const dup = t.ctx.store.db.prepare('INSERT OR IGNORE INTO watch_notifications (watch_id, observation_id, offer_id, created_at) VALUES (?, ?, ?, ?)').run(w.price, obs.id, offer.id, 0);++ const dup = await t.ctx.store.db.prepare('INSERT INTO watch_notifications (watch_id, observation_id, offer_id, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(w.price, obs.id, offer.id, 0);assert.strictEqual(dup.changes, 0);});await check('the same price again does not re-notify; a lower one does', async () => {t.clock.advance(HOUR);await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35.00', currency: 'USD' } });-- assert.strictEqual(matched(w.price).length, 1);++ assert.strictEqual((await matched(w.price)).length, 1);await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '29.90', currency: 'USD' } });-- assert.strictEqual(matched(w.price).length, 2);++ assert.strictEqual((await matched(w.price)).length, 2);});await check('an unknown price, another currency, or the watcher\'s own report never match a price watch', async () => {-- const before = matched(w.price).length;++ const before = (await matched(w.price)).length;const o2 = await t.submit(poster, { url: 'https://audio.example/cheap', title: 'Cheap headphones' });await t.get(`/d/${o2.slug}/observe`, { as: other, form: { availability: 'in_stock' } });await t.get(`/d/${o2.slug}/observe`, { as: other, form: { price: '10', currency: 'EUR' } });await t.get(`/d/${o2.slug}/observe`, { as: watcher, form: { price: '9', currency: 'USD' } });-- assert.strictEqual(matched(w.price).length, before);++ assert.strictEqual((await matched(w.price)).length, before);});await check('an imported observation replayed from Sources notifies once', async () => {t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW1', title: 'Wireless headphones deal', url: 'https://audio.example/wh', fields: { price: '19.99', currency: 'USD' }, retrievedAt: t.clock.now() }));-- const before = matched(w.price).length;++ const before = (await matched(w.price)).length;await t.ctx.importer.pull();-- assert.strictEqual(matched(w.price).length, before + 1);++ assert.strictEqual((await matched(w.price)).length, before + 1);// Replay the whole feed from the start: same items, same revision, same retrieval → nothing.-- t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();++ await t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();await t.ctx.importer.pull();-- assert.strictEqual(matched(w.price).length, before + 1);-- const obsCount = t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_price_observations o JOIN deal_offers f ON f.id = o.offer_id WHERE f.url = 'https://audio.example/wh'").get().n;++ assert.strictEqual((await matched(w.price)).length, before + 1);++ const obsCount = (await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_price_observations o JOIN deal_offers f ON f.id = o.offer_id WHERE f.url = 'https://audio.example/wh'").get()).n;assert.strictEqual(obsCount, 1);});await check('an observation that is already stale when imported does not notify', async () => {-- const before = matched().length;++ const before = (await matched()).length;t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW2', title: 'Headphones from last week', url: 'https://audio.example/old', fields: { price: '5', currency: 'USD' }, retrievedAt: t.clock.now() - 5 * 24 * HOUR }));await t.ctx.importer.pull();-- assert.strictEqual(matched().length, before);++ assert.strictEqual((await matched()).length, before);});await check('a deleted watch stops; a saved search never notified', async () => {const r = await t.get(`/watches/${w.keyword}/delete`, { as: watcher, form: {} });assert.strictEqual(r.status, 303);-- const before = matched(w.keyword).length;++ const before = (await matched(w.keyword)).length;await t.submit(poster, { url: 'https://audio.example/nc800', title: 'Noise cancelling headphones NC800', price: '20', currency: 'USD' });-- assert.strictEqual(matched(w.keyword).length, before);-- assert.strictEqual(matched(w.saved).length, 0);++ assert.strictEqual((await matched(w.keyword)).length, before);++ assert.strictEqual((await matched(w.saved)).length, 0);const cannot = await t.get(`/watches/${w.price}/delete`, { as: other, form: {} });assert.strictEqual(cannot.status, 404, 'someone else\'s watch is not found');});@@@ -138,7 +138,7 @@ const { sourceItem } = require('./helpers/mocks');const sandbox = await t.get('/api/v1/watches', { as: app('app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app', { on_behalf_of: other.subject, env: 'sandbox' }) });assert.strictEqual(sandbox.status, 401);assert.strictEqual(sandbox.json().code, 'token.sandbox_refused');-- assert.strictEqual(t.ctx.watches.list(watcher.subject).length, 2, 'the watcher\'s watches are untouched');++ assert.strictEqual((await t.ctx.watches.list(watcher.subject)).length, 2, 'the watcher\'s watches are untouched');});await t.close();
@@ -18,6 +18,22 @@ TRUST_PROXY=2
@@ -18,6 +18,22 @@ TRUST_PROXY=2
#DEALS_LIMITS_HOUR=3000
#DEALS_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-deals/deals.db)
# SQLite database (production unit: /var/lib/openvibe-deals/deals.db)
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh deals: 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.
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh deals: 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.
DEALS_DB_PATH=./data/deals.db
DEALS_DB_PATH=./data/deals.db
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
@@ -9,6 +9,9 @@ jobs:
@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: deals
contracts-service: deals
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -24,7 +24,7 @@ structured data, the comparison, the feeds and Search.
@@ -24,7 +24,7 @@ structured data, the comparison, the feeds and Search.
## Owns
## Owns
-The nine charter tables live in Deals' own SQLite (`DEALS_DB_PATH`):
+The nine charter tables live in Deals' own PostgreSQL database (`ov_deals` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
| Charter table | What it is |
| Charter table | What it is |
|---|---|
|---|---|
@@ -161,7 +161,7 @@ Snapshots are taken on every vote change, merge, unmerge and creation, and by th
@@ -161,7 +161,7 @@ Snapshots are taken on every vote change, merge, unmerge and creation, and by th
active offer of the last 14 days at **one shared `t`**, so the hot list compares numbers computed at
active offer of the last 14 days at **one shared `t`**, so the hot list compares numbers computed at
the same moment.
the same moment.
-**Abuse controls (server-side, SQLite windows shared by forms and API):**
+**Abuse controls (server-side, database windows shared by forms and API):**
| Control | Default |
| Control | Default |
|---|---|
|---|---|
@@ -246,9 +246,11 @@ render time, so the cache stays short); signed-in views, forms, API responses, e
@@ -246,9 +246,11 @@ render time, so the cache stays short); signed-in views, forms, API responses, e
## Depends on
## Depends on
-- **Packages** (pinned release tarballs): `openvibe-publishing` v0.4.0 (seo gate, JSON-LD, feeds,
- sitemaps, index-hooks, discussion, ssr), `openvibe-contracts` v0.53.0, `openvibe-shared` v1.25.0
- (chrome, app icon, footer, legal, release, metrics, ready), `openvibe-sdk` v0.12.0 (outbox, inbox,
+- **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 release tarballs): `openvibe-publishing` v1.0.0 (seo gate, JSON-LD, feeds,
+ sitemaps, index-hooks, discussion, ssr), `openvibe-contracts` v0.76.0, `openvibe-shared` v1.25.0
+ (Frame, app icon, footer, legal, release, metrics, ready), `openvibe-sdk` v0.20.0 (outbox, inbox,
webhook signatures v2, service tokens, per-actor limits).
webhook signatures v2, service tokens, per-actor limits).
- **OpenVibe.Network:** SSO (OAuth client `deals`, registered in production), JWKS,
- **OpenVibe.Network:** SSO (OAuth client `deals`, registered in production), JWKS,
`identity.subject.resolve`.
`identity.subject.resolve`.
@@ -370,10 +372,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4840 (set OV_OAUTH
@@ -370,10 +372,13 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4840 (set OV_OAUTH
Production deploys with `sudo ovhost deploy deals` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy deals` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.deals`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.deals`, install on a lockfile change, restart, wait for `/api/ready`).
-The unit is `openvibe-deals.service` on `127.0.0.1:4840`, the env file `/etc/openvibe/deals.env`.
+The unit is `openvibe-deals.service` on `127.0.0.1:4840`, the env file `/etc/openvibe/deals.env`. The database is
+`ov_deals` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh deals` 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-deals/deals.db` stays read-only for 7 days as the rollback.
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
-restart; afterwards `sudo ovhost rollback deals --to <sha>`. Nothing blocks a rollback: the schema
-code only adds tables and columns.
+restart; afterwards `sudo ovhost rollback deals --to <sha>`. Migrations only add tables and columns.
First install (done once; kept for a rebuild):
First install (done once; kept for a rebuild):
@@ -4,11 +4,16 @@
@@ -4,11 +4,16 @@
"code": true,
"code": true,
"wave": "W18",
"wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express 4, better-sqlite3 11",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4840,
"port": 4840,
"domain": "openvibe.deals",
"domain": "openvibe.deals",
- "contracts": "openvibe-contracts v0.53.0",
- "packages": ["openvibe-publishing v0.4.0", "openvibe-contracts v0.53.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
+ "contracts": "openvibe-contracts v0.76.0",
+ "packages": [
+ "openvibe-publishing v1.0.0",
+ "openvibe-contracts v0.76.0",
+ "openvibe-shared v1.25.0",
+ "openvibe-sdk v0.20.0"
+ ],
"capabilities": [
"capabilities": [
"deals.offer.submit",
"deals.offer.submit",
"deals.offer.update",
"deals.offer.update",
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
Description=OpenVibe.Deals — community deals with timestamped price observations
Description=OpenVibe.Deals — community deals with timestamped price observations
# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be up first, but Deals
# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be up first, but Deals
# degrades gracefully without them: pages and feeds serve; sign-in, events and imports wait (see /api/ready).
# degrades gracefully without them: pages and feeds serve; sign-in, events and imports wait (see /api/ready).
-After=network.target
-Wants=openvibe-network.service
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
new file mode 100644
new file mode 100644
@@ -0,0 +1,284 @@
@@ -0,0 +1,284 @@
+-- phase: expand
+-- OpenVibe.Deals 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 deal_stores (
+ id text COLLATE "C" PRIMARY KEY, -- dst_<ULID>
+ domain text COLLATE "C" NOT NULL UNIQUE, -- lower-case host without www.
+ name text COLLATE "C", -- NULL: shown as the domain (never invented)
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE deal_products (
+ id text COLLATE "C" PRIMARY KEY, -- dpr_<ULID>
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ name text COLLATE "C" NOT NULL,
+ brand text COLLATE "C",
+ category text COLLATE "C",
+ description text COLLATE "C",
+ created_by text COLLATE "C", -- usr_… or svc:… (imports)
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE deal_product_aliases (
+ id text COLLATE "C" PRIMARY KEY, -- dpa_<ULID>
+ product_id text COLLATE "C" NOT NULL REFERENCES deal_products(id),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('name','gtin','mpn','sku','url')),
+ value text COLLATE "C" NOT NULL,
+ value_norm text COLLATE "C" NOT NULL,
+ source text COLLATE "C" NOT NULL CHECK (source IN ('community','import','moderator')),
+ created_by text COLLATE "C",
+ created_at bigint NOT NULL,
+ UNIQUE (kind, value_norm)
+);
+CREATE INDEX deal_product_aliases_product ON deal_product_aliases (product_id);
+
+CREATE TABLE deal_offers (
+ id text COLLATE "C" PRIMARY KEY, -- dof_<ULID>
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ title text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ url text COLLATE "C" NOT NULL, -- the offer link as normalised (tracking stripped)
+ url_norm text COLLATE "C" NOT NULL,
+ store_id text COLLATE "C" REFERENCES deal_stores(id),
+ product_id text COLLATE "C" REFERENCES deal_products(id),
+ category text COLLATE "C",
+ origin text COLLATE "C" NOT NULL CHECK (origin IN ('community','import')),
+ submitted_by text COLLATE "C", -- usr_… (community) / NULL (import)
+ text_origin text COLLATE "C" NOT NULL DEFAULT 'human' CHECK (text_origin IN ('human','imported','ai')),
+ review_state text COLLATE "C" NOT NULL DEFAULT 'not_required' CHECK (review_state IN ('not_required','pending','reviewed')),
+ reviewed_by text COLLATE "C",
+ reviewed_at bigint,
+ ai_summary text COLLATE "C", -- AI-assisted text, disclosed, noindex until reviewed
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active','expired','disabled')),
+ expires_at bigint, -- a STATED expiry (NULL: unknown, never assumed)
+ expired_at bigint,
+ expired_reason text COLLATE "C",
+ disabled_at bigint,
+ disabled_reason text COLLATE "C",
+ disabled_by text COLLATE "C",
+ merged_into text COLLATE "C" REFERENCES deal_offers(id), -- duplicate of … (reversible; nothing is moved)
+ merged_at bigint,
+ merged_by text COLLATE "C",
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ CHECK (merged_into IS NULL OR merged_into <> id)
+);
+CREATE INDEX deal_offers_url ON deal_offers (url_norm);
+CREATE INDEX deal_offers_listing ON deal_offers (status, merged_into, created_at);
+CREATE INDEX deal_offers_merged ON deal_offers (merged_into);
+CREATE INDEX deal_offers_product ON deal_offers (product_id);
+CREATE INDEX deal_offers_store ON deal_offers (store_id);
+
+CREATE TABLE deal_offer_sources (
+ id text COLLATE "C" PRIMARY KEY, -- dos_<ULID>
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('submission','community','sources_item')),
+ ref_service text COLLATE "C" NOT NULL, -- 'deals' | 'sources'
+ ref_type text COLLATE "C" NOT NULL, -- 'submission' | 'observer' | 'item'
+ ref_id text COLLATE "C" NOT NULL, -- offer id | usr_… | itm_…
+ ref_part text COLLATE "C" NOT NULL DEFAULT '', -- which offer inside one Sources item
+ ref_revision bigint, -- the Sources item revision last imported
+ source_key text COLLATE "C", -- the Sources registry key (display)
+ url text COLLATE "C",
+ label text COLLATE "C",
+ license_note text COLLATE "C",
+ submitted_by text COLLATE "C",
+ retrieved_at bigint, -- Sources provenance.retrieved_at
+ removed_at bigint,
+ removed_reason text COLLATE "C",
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ UNIQUE (kind, ref_service, ref_id, ref_part)
+);
+CREATE INDEX deal_offer_sources_offer ON deal_offer_sources (offer_id);
+
+CREATE TABLE deal_price_observations (
+ id text COLLATE "C" PRIMARY KEY, -- dpo_<ULID>
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id),
+ source_id text COLLATE "C" NOT NULL REFERENCES deal_offer_sources(id),
+ observed_at bigint NOT NULL, -- when the source saw it (not when we stored it)
+ recorded_at bigint NOT NULL,
+ price text COLLATE "C", -- decimal exactly as stated; NULL = not stated
+ price_num double precision,
+ currency text COLLATE "C", -- ISO 4217; NULL = not stated
+ shipping text COLLATE "C", -- decimal as stated (same currency); NULL = not stated
+ shipping_num double precision,
+ shipping_note text COLLATE "C",
+ condition text COLLATE "C" CHECK (condition IS NULL OR condition IN ('new','used','refurbished','damaged')),
+ availability text COLLATE "C" CHECK (availability IS NULL OR availability IN ('in_stock','out_of_stock','preorder','discontinued','limited','sold_out','online_only','in_store_only')),
+ origin text COLLATE "C" NOT NULL CHECK (origin IN ('community','import','import_refresh')),
+ observed_by text COLLATE "C", -- usr_… for people; NULL for imports
+ source_revision bigint,
+ note text COLLATE "C",
+ CHECK ((price IS NULL) = (price_num IS NULL)),
+ CHECK ((shipping IS NULL) = (shipping_num IS NULL))
+);
+CREATE INDEX deal_price_observations_offer ON deal_price_observations (offer_id, observed_at);
+
+CREATE TABLE deal_votes (
+ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id),
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- insertion order (the SQLite rowid tiebreak)
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ value bigint NOT NULL CHECK (value IN (-1, 0, 1)), -- 0 = removed (kept for history)
+ weight double precision NOT NULL, -- the weight when cast (new accounts count less)
+ ip_hash text COLLATE "C",
+ via text COLLATE "C" NOT NULL DEFAULT 'user' CHECK (via IN ('user','service')),
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (offer_id, subject)
+);
+CREATE INDEX deal_votes_subject ON deal_votes (subject, updated_at);
+CREATE INDEX deal_votes_ip ON deal_votes (ip_hash, offer_id);
+
+CREATE TABLE deal_hotness_snapshots (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ offer_id text COLLATE "C" NOT NULL REFERENCES deal_offers(id), -- the canonical (root) offer
+ computed_at bigint NOT NULL,
+ formula text COLLATE "C" NOT NULL,
+ up_count bigint NOT NULL,
+ down_count bigint NOT NULL,
+ up_weight double precision NOT NULL,
+ down_weight double precision NOT NULL,
+ score double precision NOT NULL,
+ hot double precision NOT NULL,
+ first_seen_at bigint NOT NULL,
+ reason text COLLATE "C" NOT NULL
+);
+CREATE INDEX deal_hotness_offer ON deal_hotness_snapshots (offer_id, id);
+
+CREATE TABLE deal_watches (
+ id text COLLATE "C" PRIMARY KEY, -- dwt_<ULID>
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('keyword','product','price_below','search')),
+ query text COLLATE "C",
+ product_id text COLLATE "C" REFERENCES deal_products(id),
+ max_price text COLLATE "C",
+ max_price_num double precision,
+ currency text COLLATE "C",
+ notify bigint NOT NULL DEFAULT 1, -- 0: a saved search (never notifies)
+ label text COLLATE "C",
+ created_at bigint NOT NULL,
+ deleted_at bigint,
+ CHECK (kind <> 'search' OR notify = 0),
+ CHECK (kind <> 'price_below' OR (max_price_num IS NOT NULL AND currency IS NOT NULL))
+);
+CREATE INDEX deal_watches_subject ON deal_watches (subject, deleted_at);
+CREATE INDEX deal_watches_active ON deal_watches (notify, deleted_at, kind);
+
+CREATE TABLE deal_flags (
+ id text COLLATE "C" PRIMARY KEY, -- dfl_<ULID>
+ offer_id text COLLATE "C" REFERENCES deal_offers(id),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('expired','price_wrong','spam','duplicate','other','vote_ring')),
+ origin text COLLATE "C" NOT NULL CHECK (origin IN ('user','system')),
+ reporter text COLLATE "C", -- usr_… (NULL for system flags)
+ reason text COLLATE "C",
+ details text COLLATE "C", -- JSON
+ dedupe_key text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved','dismissed')),
+ resolved_by text COLLATE "C",
+ resolved_at bigint,
+ resolution text COLLATE "C",
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE UNIQUE INDEX deal_flags_open ON deal_flags (dedupe_key) WHERE status = 'open';
+CREATE INDEX deal_flags_status ON deal_flags (status, created_at);
+
+CREATE TABLE watch_notifications (
+ watch_id text COLLATE "C" NOT NULL REFERENCES deal_watches(id),
+ observation_id text COLLATE "C" NOT NULL REFERENCES deal_price_observations(id),
+ offer_id text COLLATE "C" NOT NULL, -- the canonical offer when it matched
+ price_num double precision,
+ currency text COLLATE "C",
+ event_id text COLLATE "C",
+ created_at bigint NOT NULL,
+ PRIMARY KEY (watch_id, observation_id)
+);
+CREATE INDEX watch_notifications_offer ON watch_notifications (watch_id, offer_id);
+
+CREATE TABLE moderation_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ action text COLLATE "C" NOT NULL, -- merge | unmerge | expire | disable | enable | review | flag.resolve | flag.dismiss
+ offer_id text COLLATE "C",
+ target_id text COLLATE "C",
+ actor text COLLATE "C" NOT NULL, -- usr_… | svc:…
+ reason text COLLATE "C",
+ before text COLLATE "C", -- JSON
+ after text COLLATE "C", -- JSON
+ at bigint NOT NULL
+);
+CREATE INDEX moderation_log_offer ON moderation_log (offer_id, id);
+
+CREATE TABLE rate_events (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ kind text COLLATE "C" NOT NULL,
+ key text COLLATE "C" NOT NULL,
+ at bigint NOT NULL
+);
+CREATE INDEX rate_events_key ON rate_events (kind, key, at);
+
+CREATE TABLE import_state (
+ key text COLLATE "C" PRIMARY KEY,
+ value text COLLATE "C",
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE subject_projections (
+ subject text COLLATE "C" PRIMARY KEY,
+ username text COLLATE "C",
+ display_name text COLLATE "C",
+ avatar_url text COLLATE "C",
+ first_seen_at bigint, -- when Deals first saw this person sign in
+ refreshed_at bigint NOT NULL
+);
+
+-- openvibe-publishing/discussion (prefix deal_offer)
+CREATE TABLE IF NOT EXISTS deal_offer_discussion_refs (
+ entity_id text COLLATE "C" PRIMARY KEY,
+ thread_id text NOT NULL,
+ ref jsonb NOT NULL,
+ resolved_at bigint NOT NULL
+);
+
+-- openvibe-publishing/index-hooks (prefix deal)
+CREATE TABLE IF NOT EXISTS deal_index_revisions (
+ owner text COLLATE "C" NOT NULL,
+ type text COLLATE "C" NOT NULL,
+ id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL,
+ hash text NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (owner, type, id)
+);
+
+-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
+CREATE TABLE IF NOT EXISTS idempotency_receipts (
+ consumer text NOT NULL,
+ event_id text NOT NULL,
+ processed_at bigint NOT NULL,
+ PRIMARY KEY (consumer, event_id)
+);
+
+-- openvibe-sdk/events PostgreSQL outbox
+CREATE TABLE IF NOT EXISTS event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
@@ -15,18 +15,34 @@
@@ -15,18 +15,34 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
+ "pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -228,6 +244,15 @@
@@ -228,6 +244,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -310,6 +335,15 @@
@@ -310,6 +335,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -746,6 +780,49 @@
@@ -746,6 +780,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -810,12 +887,24 @@
@@ -810,12 +887,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1015,9 +1104,9 @@
@@ -1015,9 +1104,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.53.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",
+ "version": "0.76.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1031,15 +1120,15 @@
@@ -1031,15 +1120,15 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"node_modules/openvibe-publishing": {
- "version": "0.4.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
},
},
"peerDependencies": {
"peerDependencies": {
- "better-sqlite3": ">=11",
+ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
"openvibe-shared": ">=1.5.0"
},
},
"peerDependenciesMeta": {
"peerDependenciesMeta": {
@@ -1049,12 +1138,32 @@
@@ -1049,12 +1138,32 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.12.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
+ "version": "0.20.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
+ "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
@@ -1081,6 +1190,134 @@
@@ -1081,6 +1190,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1204,6 +1441,27 @@
@@ -1204,6 +1441,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1419,6 +1677,21 @@
@@ -1419,6 +1677,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1546,6 +1819,15 @@
@@ -1546,6 +1819,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -2,14 +2,20 @@
@@ -2,14 +2,20 @@
"name": "openvibe-deals",
"name": "openvibe-deals",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Deals — community-submitted and source-imported deals with timestamped price observations, server-authoritative votes and hotness, reversible duplicate merges, watches and saved searches, server-rendered and useful without JavaScript.",
+ "description": "OpenVibe.Deals \u2014 community-submitted and source-imported deals with timestamped price observations, server-authoritative votes and hotness, reversible duplicate merges, watches and saved searches, server-rendered and useful without JavaScript.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/run.js"
+ "test": "node test/run.js",
+ "test:pg": "DEALS_TEST_STORE=pg node test/run.js"
},
},
- "keywords": ["openvibe", "deals", "prices", "publishing"],
+ "keywords": [
+ "openvibe",
+ "deals",
+ "prices",
+ "publishing"
+ ],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Deals",
"repository": "github:OpenVibers/OpenVibe.Deals",
@@ -20,13 +26,16 @@
@@ -20,13 +26,16 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
+ "pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
- "jsonwebtoken": "^9.0.2"
+ "jsonwebtoken": "^9.0.2",
+ "@electric-sql/pglite": "^0.5.8"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Deals's SQLite database (DEALS_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: 'deals', 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 };
@@ -49,11 +49,12 @@ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -49,11 +49,12 @@ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
/** opts: config, dbPath, now (clock), fetchImpl, auth, log, limitsNow (the per-actor limiter's clock, tests) */
/** opts: config, dbPath, now (clock), fetchImpl, auth, log, limitsNow (the per-actor limiter's clock, tests) */
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
+ const store = opts.store || await openStore(config, { now: opts.now, log });
const outbox = createDealsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const outbox = createDealsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const people = createPeople({ store, config, fetchImpl });
const people = createPeople({ store, config, fetchImpl });
@@ -87,7 +88,10 @@ function createApp(opts = {}) {
@@ -87,7 +88,10 @@ function createApp(opts = {}) {
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Per-actor limits (http/actor-limits.js) for the API and the page forms, counted once each router
// Per-actor limits (http/actor-limits.js) for the API and the page forms, counted once each router
// resolved req.viewer; the per-address limits below and the per-person abuse controls stay.
// resolved req.viewer; the per-address limits below and the per-person abuse controls stay.
- ctx.actorLimits = 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.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -118,7 +122,7 @@ function createApp(opts = {}) {
@@ -118,7 +122,7 @@ function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-deals', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-deals', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- const readiness = createDealsReadiness({ store, auth, outbox, importer, worker, limits, release: release.release });
+ const readiness = createDealsReadiness({ store, auth, outbox, importer, worker, limits, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
@@ -66,7 +66,7 @@ function createViewerResolver({ auth, config, people }) {
@@ -66,7 +66,7 @@ function createViewerResolver({ auth, config, people }) {
const claims = await auth.verify(token);
const claims = await auth.verify(token);
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
- if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
+ if (subject && people) { try { await people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
// Staff = the contracts staff map's staff.content.moderate (ADR-022), or one of this product's own moderators.
// Staff = the contracts staff map's staff.content.moderate (ADR-022), or one of this product's own moderators.
const staff = staffMap.can(claims, 'staff.content.moderate') || Boolean(subject && moderators.has(subject));
const staff = staffMap.can(claims, 'staff.content.moderate') || Boolean(subject && moderators.has(subject));
return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };
return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };
@@ -80,7 +80,7 @@ function createViewerResolver({ auth, config, people }) {
@@ -80,7 +80,7 @@ function createViewerResolver({ auth, config, people }) {
const payload = decodeJwtPayload(token);
const payload = decodeJwtPayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (opts.services === false) return ANONYMOUS;
if (opts.services === false) return ANONYMOUS;
- return fromServiceToken(req, token);
+ return await fromServiceToken(req, token);
}
}
}
}
const token = extractToken(req);
const token = extractToken(req);
@@ -50,11 +50,11 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
@@ -50,11 +50,11 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
publicUrl: config.community.publicUrl,
publicUrl: config.community.publicUrl,
/** The stored thread id (no network call), or null. */
/** The stored thread id (no network call), or null. */
- knownThread(offer) { const k = store.discussion.get(offer.id); return k ? k.threadId : null; },
+ async knownThread(offer) { const k = await store.discussion.get(offer.id); return k ? k.threadId : null; },
/** The stored thread id, or resolve it once through Community. */
/** The stored thread id, or resolve it once through Community. */
async threadFor(offer, label, ctx) {
async threadFor(offer, label, ctx) {
- const known = store.discussion.get(offer.id);
+ const known = await store.discussion.get(offer.id);
if (known) return known.threadId;
if (known) return known.threadId;
if (!discussion) return null;
if (!discussion) return null;
const out = await store.discussion.threadFor(offer.id, refFor(offer, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
const out = await store.discussion.threadFor(offer.id, refFor(offer, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
@@ -64,13 +64,13 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
@@ -64,13 +64,13 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
/** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
/** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
async readThread(threadId, { after, ctx } = {}) {
async readThread(threadId, { after, ctx } = {}) {
const qs = after ? `?after=${encodeURIComponent(after)}` : '';
const qs = after ? `?after=${encodeURIComponent(after)}` : '';
- return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
+ return await call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
},
},
/** Comment as the signed-in member. */
/** Comment as the signed-in member. */
async comment(threadId, subject, { message, parentId } = {}, ctx) {
async comment(threadId, subject, { message, parentId } = {}, ctx) {
if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
- return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
+ return await call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
});
});
},
},
@@ -79,7 +79,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
@@ -79,7 +79,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
* Best effort: hide an offer's thread when moderators disable it, show it again when enabled. Needs community.comment.moderate.
* Best effort: hide an offer's thread when moderators disable it, show it again when enabled. Needs community.comment.moderate.
*/
*/
async setThreadVisibility(offer, visibility, ctx) {
async setThreadVisibility(offer, visibility, ctx) {
- const known = store.discussion.get(offer.id);
+ const known = await store.discussion.get(offer.id);
if (!known || !modTokens) return false;
if (!known || !modTokens) return false;
try {
try {
await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
@@ -23,6 +23,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -23,6 +23,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
}) : null;
}) : null;
const q = {
const q = {
get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),
get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),
+ getMany: db.prepare('SELECT * FROM subject_projections WHERE subject = ANY(?)'),
byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),
byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),
put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)
put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
@@ -32,9 +33,9 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -32,9 +33,9 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
};
};
const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
- function remember(subject, p) {
+ async function remember(subject, p) {
if (!ids.isSubjectId('user', subject) || !p) return;
if (!ids.isSubjectId('user', subject) || !p) return;
- q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
+ await q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
}
}
async function resolve(subjects) {
async function resolve(subjects) {
@@ -48,7 +49,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -48,7 +49,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
if (res.status === 401) tokens.invalidate && tokens.invalidate();
if (res.status === 401) tokens.invalidate && tokens.invalidate();
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);
- for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);
+ for (const s of subjects) if (data.results[s]) await remember(s, data.results[s]);
}
}
const shape = (row, subject) => (row
const shape = (row, subject) => (row
@@ -58,15 +59,17 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -58,15 +59,17 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
return {
return {
enabled,
enabled,
/** From a verified sign-in: the member's own claims are a fresh projection. */
/** From a verified sign-in: the member's own claims are a fresh projection. */
- rememberClaims(subject, claims) { remember(subject, claims); q.seen.run(store.now(), subject); },
+ async rememberClaims(subject, claims) { await remember(subject, claims); await q.seen.run(store.now(), subject); },
/** When Deals first saw this person signed in (abuse controls: account age evidence), or null. */
/** When Deals first saw this person signed in (abuse controls: account age evidence), or null. */
- firstSeen(subject) { const r = q.firstSeen.get(subject); return r ? r.first_seen_at : null; },
+ async firstSeen(subject) { const r = await q.firstSeen.get(subject); return r ? r.first_seen_at : null; },
/** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */
/** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */
async many(subjects) {
async many(subjects) {
const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];
const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];
- const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
+ const read = async () => new Map((list.length ? await q.getMany.all(list) : []).map((r) => [r.subject, r]));
+ let rows = await read();
+ const missing = list.filter((s) => { const r = rows.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
if (missing.length && tokens) {
if (missing.length && tokens) {
let timer;
let timer;
await Promise.race([
await Promise.race([
@@ -74,19 +77,20 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -74,19 +77,20 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),
new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),
]);
]);
clearTimeout(timer);
clearTimeout(timer);
+ rows = await read();
}
}
- return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));
+ return new Map(list.map((s) => [s, shape(rows.get(s) || null, s)]));
},
},
async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },
async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },
/** The subject a username belongs to, from people Deals has seen. */
/** The subject a username belongs to, from people Deals has seen. */
- byUsername(username) {
- const row = q.byUsername.get(String(username || '').toLowerCase());
+ async byUsername(username) {
+ const row = await q.byUsername.get(String(username || '').toLowerCase());
return row ? shape(row, row.subject) : null;
return row ? shape(row, row.subject) : null;
},
},
- cached: (subject) => shape(q.get.get(subject), subject),
+ cached: async (subject) => shape(await q.get.get(subject), subject),
};
};
}
}
@@ -39,8 +39,8 @@ function createSources({ config, fetchImpl = globalThis.fetch }) {
@@ -39,8 +39,8 @@ function createSources({ config, fetchImpl = globalThis.fetch }) {
return {
return {
enabled,
enabled,
- items: ({ after = 0, limit = 100 } = {}, ctx) => call(`/api/v1/items?category=deals&after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}&include_removed=1`, ctx),
- item: (id, ctx) => call(`/api/v1/items/${encodeURIComponent(id)}`, ctx),
+ items: async ({ after = 0, limit = 100 } = {}, ctx) => await call(`/api/v1/items?category=deals&after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}&include_removed=1`, ctx),
+ item: async (id, ctx) => await call(`/api/v1/items/${encodeURIComponent(id)}`, ctx),
};
};
}
}
@@ -37,6 +37,10 @@ function load(env = process.env) {
@@ -37,6 +37,10 @@ function load(env = process.env) {
hour: Math.max(1, int(env.DEALS_LIMITS_HOUR, 3000)),
hour: Math.max(1, int(env.DEALS_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:deals:' },
+ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.DEALS_DB_PATH || './data/deals.db',
dbPath: env.DEALS_DB_PATH || './data/deals.db',
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
- * Deals' own SQLite database: created on boot, idempotently. Nothing here is shared with another
- * service.
+ * Deals' 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):
* The nine charter tables (roadmap §15.13):
*
*
@@ -32,266 +32,53 @@
@@ -32,266 +32,53 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS deal_stores (
- id TEXT PRIMARY KEY, -- dst_<ULID>
- domain TEXT NOT NULL UNIQUE, -- lower-case host without www.
- name TEXT, -- NULL: shown as the domain (never invented)
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS deal_products (
- id TEXT PRIMARY KEY, -- dpr_<ULID>
- slug TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- brand TEXT,
- category TEXT,
- description TEXT,
- created_by TEXT, -- usr_… or svc:… (imports)
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS deal_product_aliases (
- id TEXT PRIMARY KEY, -- dpa_<ULID>
- product_id TEXT NOT NULL REFERENCES deal_products(id),
- kind TEXT NOT NULL CHECK (kind IN ('name','gtin','mpn','sku','url')),
- value TEXT NOT NULL,
- value_norm TEXT NOT NULL,
- source TEXT NOT NULL CHECK (source IN ('community','import','moderator')),
- created_by TEXT,
- created_at INTEGER NOT NULL,
- UNIQUE (kind, value_norm)
-);
-CREATE INDEX IF NOT EXISTS deal_product_aliases_product ON deal_product_aliases (product_id);
-
-CREATE TABLE IF NOT EXISTS deal_offers (
- id TEXT PRIMARY KEY, -- dof_<ULID>
- slug TEXT NOT NULL UNIQUE,
- title TEXT NOT NULL,
- description TEXT,
- url TEXT NOT NULL, -- the offer link as normalised (tracking stripped)
- url_norm TEXT NOT NULL,
- store_id TEXT REFERENCES deal_stores(id),
- product_id TEXT REFERENCES deal_products(id),
- category TEXT,
- origin TEXT NOT NULL CHECK (origin IN ('community','import')),
- submitted_by TEXT, -- usr_… (community) / NULL (import)
- text_origin TEXT NOT NULL DEFAULT 'human' CHECK (text_origin IN ('human','imported','ai')),
- review_state TEXT NOT NULL DEFAULT 'not_required' CHECK (review_state IN ('not_required','pending','reviewed')),
- reviewed_by TEXT,
- reviewed_at INTEGER,
- ai_summary TEXT, -- AI-assisted text, disclosed, noindex until reviewed
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','expired','disabled')),
- expires_at INTEGER, -- a STATED expiry (NULL: unknown, never assumed)
- expired_at INTEGER,
- expired_reason TEXT,
- disabled_at INTEGER,
- disabled_reason TEXT,
- disabled_by TEXT,
- merged_into TEXT REFERENCES deal_offers(id), -- duplicate of … (reversible; nothing is moved)
- merged_at INTEGER,
- merged_by TEXT,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- CHECK (merged_into IS NULL OR merged_into <> id)
-);
-CREATE INDEX IF NOT EXISTS deal_offers_url ON deal_offers (url_norm);
-CREATE INDEX IF NOT EXISTS deal_offers_listing ON deal_offers (status, merged_into, created_at);
-CREATE INDEX IF NOT EXISTS deal_offers_merged ON deal_offers (merged_into);
-CREATE INDEX IF NOT EXISTS deal_offers_product ON deal_offers (product_id);
-CREATE INDEX IF NOT EXISTS deal_offers_store ON deal_offers (store_id);
-
-CREATE TABLE IF NOT EXISTS deal_offer_sources (
- id TEXT PRIMARY KEY, -- dos_<ULID>
- offer_id TEXT NOT NULL REFERENCES deal_offers(id),
- kind TEXT NOT NULL CHECK (kind IN ('submission','community','sources_item')),
- ref_service TEXT NOT NULL, -- 'deals' | 'sources'
- ref_type TEXT NOT NULL, -- 'submission' | 'observer' | 'item'
- ref_id TEXT NOT NULL, -- offer id | usr_… | itm_…
- ref_part TEXT NOT NULL DEFAULT '', -- which offer inside one Sources item
- ref_revision INTEGER, -- the Sources item revision last imported
- source_key TEXT, -- the Sources registry key (display)
- url TEXT,
- label TEXT,
- license_note TEXT,
- submitted_by TEXT,
- retrieved_at INTEGER, -- Sources provenance.retrieved_at
- removed_at INTEGER,
- removed_reason TEXT,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- UNIQUE (kind, ref_service, ref_id, ref_part)
-);
-CREATE INDEX IF NOT EXISTS deal_offer_sources_offer ON deal_offer_sources (offer_id);
-
-CREATE TABLE IF NOT EXISTS deal_price_observations (
- id TEXT PRIMARY KEY, -- dpo_<ULID>
- offer_id TEXT NOT NULL REFERENCES deal_offers(id),
- source_id TEXT NOT NULL REFERENCES deal_offer_sources(id),
- observed_at INTEGER NOT NULL, -- when the source saw it (not when we stored it)
- recorded_at INTEGER NOT NULL,
- price TEXT, -- decimal exactly as stated; NULL = not stated
- price_num REAL,
- currency TEXT, -- ISO 4217; NULL = not stated
- shipping TEXT, -- decimal as stated (same currency); NULL = not stated
- shipping_num REAL,
- shipping_note TEXT,
- condition TEXT CHECK (condition IS NULL OR condition IN ('new','used','refurbished','damaged')),
- availability TEXT CHECK (availability IS NULL OR availability IN ('in_stock','out_of_stock','preorder','discontinued','limited','sold_out','online_only','in_store_only')),
- origin TEXT NOT NULL CHECK (origin IN ('community','import','import_refresh')),
- observed_by TEXT, -- usr_… for people; NULL for imports
- source_revision INTEGER,
- note TEXT,
- CHECK ((price IS NULL) = (price_num IS NULL)),
- CHECK ((shipping IS NULL) = (shipping_num IS NULL))
-);
-CREATE INDEX IF NOT EXISTS deal_price_observations_offer ON deal_price_observations (offer_id, observed_at);
-
-CREATE TABLE IF NOT EXISTS deal_votes (
- offer_id TEXT NOT NULL REFERENCES deal_offers(id),
- subject TEXT NOT NULL, -- usr_…
- value INTEGER NOT NULL CHECK (value IN (-1, 0, 1)), -- 0 = removed (kept for history)
- weight REAL NOT NULL, -- the weight when cast (new accounts count less)
- ip_hash TEXT,
- via TEXT NOT NULL DEFAULT 'user' CHECK (via IN ('user','service')),
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- PRIMARY KEY (offer_id, subject)
-);
-CREATE INDEX IF NOT EXISTS deal_votes_subject ON deal_votes (subject, updated_at);
-CREATE INDEX IF NOT EXISTS deal_votes_ip ON deal_votes (ip_hash, offer_id);
-
-CREATE TABLE IF NOT EXISTS deal_hotness_snapshots (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- offer_id TEXT NOT NULL REFERENCES deal_offers(id), -- the canonical (root) offer
- computed_at INTEGER NOT NULL,
- formula TEXT NOT NULL,
- up_count INTEGER NOT NULL,
- down_count INTEGER NOT NULL,
- up_weight REAL NOT NULL,
- down_weight REAL NOT NULL,
- score REAL NOT NULL,
- hot REAL NOT NULL,
- first_seen_at INTEGER NOT NULL,
- reason TEXT NOT NULL
-);
-CREATE INDEX IF NOT EXISTS deal_hotness_offer ON deal_hotness_snapshots (offer_id, id);
-
-CREATE TABLE IF NOT EXISTS deal_watches (
- id TEXT PRIMARY KEY, -- dwt_<ULID>
- subject TEXT NOT NULL, -- usr_…
- kind TEXT NOT NULL CHECK (kind IN ('keyword','product','price_below','search')),
- query TEXT,
- product_id TEXT REFERENCES deal_products(id),
- max_price TEXT,
- max_price_num REAL,
- currency TEXT,
- notify INTEGER NOT NULL DEFAULT 1, -- 0: a saved search (never notifies)
- label TEXT,
- created_at INTEGER NOT NULL,
- deleted_at INTEGER,
- CHECK (kind <> 'search' OR notify = 0),
- CHECK (kind <> 'price_below' OR (max_price_num IS NOT NULL AND currency IS NOT NULL))
-);
-CREATE INDEX IF NOT EXISTS deal_watches_subject ON deal_watches (subject, deleted_at);
-CREATE INDEX IF NOT EXISTS deal_watches_active ON deal_watches (notify, deleted_at, kind);
-
-CREATE TABLE IF NOT EXISTS deal_flags (
- id TEXT PRIMARY KEY, -- dfl_<ULID>
- offer_id TEXT REFERENCES deal_offers(id),
- kind TEXT NOT NULL CHECK (kind IN ('expired','price_wrong','spam','duplicate','other','vote_ring')),
- origin TEXT NOT NULL CHECK (origin IN ('user','system')),
- reporter TEXT, -- usr_… (NULL for system flags)
- reason TEXT,
- details TEXT, -- JSON
- dedupe_key TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved','dismissed')),
- resolved_by TEXT,
- resolved_at INTEGER,
- resolution TEXT,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
-CREATE UNIQUE INDEX IF NOT EXISTS deal_flags_open ON deal_flags (dedupe_key) WHERE status = 'open';
-CREATE INDEX IF NOT EXISTS deal_flags_status ON deal_flags (status, created_at);
-
-CREATE TABLE IF NOT EXISTS watch_notifications (
- watch_id TEXT NOT NULL REFERENCES deal_watches(id),
- observation_id TEXT NOT NULL REFERENCES deal_price_observations(id),
- offer_id TEXT NOT NULL, -- the canonical offer when it matched
- price_num REAL,
- currency TEXT,
- event_id TEXT,
- created_at INTEGER NOT NULL,
- PRIMARY KEY (watch_id, observation_id)
-);
-CREATE INDEX IF NOT EXISTS watch_notifications_offer ON watch_notifications (watch_id, offer_id);
-
-CREATE TABLE IF NOT EXISTS moderation_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- action TEXT NOT NULL, -- merge | unmerge | expire | disable | enable | review | flag.resolve | flag.dismiss
- offer_id TEXT,
- target_id TEXT,
- actor TEXT NOT NULL, -- usr_… | svc:…
- reason TEXT,
- before TEXT, -- JSON
- after TEXT, -- JSON
- at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS moderation_log_offer ON moderation_log (offer_id, id);
-
-CREATE TABLE IF NOT EXISTS rate_events (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- kind TEXT NOT NULL,
- key TEXT NOT NULL,
- at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS rate_events_key ON rate_events (kind, key, at);
-
-CREATE TABLE IF NOT EXISTS import_state (
- key TEXT PRIMARY KEY,
- value TEXT,
- updated_at INTEGER NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS subject_projections (
- subject TEXT PRIMARY KEY,
- username TEXT,
- display_name TEXT,
- avatar_url TEXT,
- first_seen_at INTEGER, -- when Deals first saw this person sign in
- refreshed_at INTEGER NOT NULL
-);
-`;
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const CHARTER_TABLES = ['deal_products', 'deal_product_aliases', 'deal_offers', 'deal_offer_sources', 'deal_votes',
const CHARTER_TABLES = ['deal_products', 'deal_product_aliases', 'deal_offers', 'deal_offer_sources', 'deal_votes',
'deal_hotness_snapshots', 'deal_watches', 'deal_price_observations', 'deal_flags'];
'deal_hotness_snapshots', 'deal_watches', 'deal_price_observations', 'deal_flags'];
/**
/**
- * Open (or create) the database. opts.now — injectable clock (epoch ms) shared by everything, so
- * tests and replays are deterministic.
+ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
+ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
+ */
+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 deals)');
+ log.warn(`[Deals] 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: 'deals', 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: 'deals-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'deals', registry, log });
+}
+
+/**
+ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms) shared by everything, so tests
+ * and replays are deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).
*/
*/
-function openStore(dbPath, { now = () => Date.now() } = {}) {
- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
- const db = new Database(dbPath);
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
- db.exec(SCHEMA);
+function createStore(db, { now = () => Date.now() } = {}) {
return {
return {
db,
db,
now,
now,
discussion: createDiscussionRefs(db, { prefix: 'deal_offer', now }),
discussion: createDiscussionRefs(db, { prefix: 'deal_offer', now }),
sequencer: createIndexSequencer(db, { prefix: 'deal', now }),
sequencer: createIndexSequencer(db, { prefix: 'deal', now }),
- tx: (fn) => db.transaction(fn)(),
+ tx: async (fn) => await db.tx(() => fn()),
close: () => db.close(),
close: () => db.close(),
};
};
}
}
-module.exports = { openStore, CHARTER_TABLES };
+/** openDb + createStore. */
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, CHARTER_TABLES, MIGRATIONS };
@@ -32,21 +32,21 @@ function createCatalog({ store }) {
@@ -32,21 +32,21 @@ function createCatalog({ store }) {
insertProduct: db.prepare(`INSERT INTO deal_products (id, slug, name, brand, category, description, created_by, created_at, updated_at)
insertProduct: db.prepare(`INSERT INTO deal_products (id, slug, name, brand, category, description, created_by, created_at, updated_at)
VALUES (@id, @slug, @name, @brand, @category, @description, @created_by, @now, @now)`),
VALUES (@id, @slug, @name, @brand, @category, @description, @created_by, @now, @now)`),
alias: db.prepare('SELECT * FROM deal_product_aliases WHERE kind = ? AND value_norm = ?'),
alias: db.prepare('SELECT * FROM deal_product_aliases WHERE kind = ? AND value_norm = ?'),
- insertAlias: db.prepare(`INSERT OR IGNORE INTO deal_product_aliases (id, product_id, kind, value, value_norm, source, created_by, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
+ insertAlias: db.prepare(`INSERT INTO deal_product_aliases (id, product_id, kind, value, value_norm, source, created_by, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),
aliases: db.prepare('SELECT kind, value, source, created_at FROM deal_product_aliases WHERE product_id = ? ORDER BY kind, value'),
aliases: db.prepare('SELECT kind, value, source, created_at FROM deal_product_aliases WHERE product_id = ? ORDER BY kind, value'),
};
};
- function ensureStore(domain, name = null) {
+ async function ensureStore(domain, name = null) {
if (!domain) return null;
if (!domain) return null;
- const existing = q.storeByDomain.get(domain);
+ const existing = await q.storeByDomain.get(domain);
if (existing) {
if (existing) {
- if (name && !existing.name) q.nameStore.run(String(name).slice(0, 120), store.now(), existing.id);
- return q.storeByDomain.get(domain);
+ if (name && !existing.name) await q.nameStore.run(String(name).slice(0, 120), store.now(), existing.id);
+ return await q.storeByDomain.get(domain);
}
}
const id = newId('dst');
const id = newId('dst');
- q.insertStore.run(id, domain, name ? String(name).slice(0, 120) : null, store.now(), store.now());
- return q.storeById.get(id);
+ await q.insertStore.run(id, domain, name ? String(name).slice(0, 120) : null, store.now(), store.now());
+ return await q.storeById.get(id);
}
}
function storeLabel(s) { return s ? (s.name || s.domain) : null; }
function storeLabel(s) { return s ? (s.name || s.domain) : null; }
@@ -57,31 +57,31 @@ function createCatalog({ store }) {
@@ -57,31 +57,31 @@ function createCatalog({ store }) {
* reported in `conflicts`), never re-pointed silently.
* reported in `conflicts`), never re-pointed silently.
* input: { name, brand, category, description, gtin, mpn, sku, url }, source, actor
* input: { name, brand, category, description, gtin, mpn, sku, url }, source, actor
*/
*/
- function resolve(input, { source = 'community', actor = null, create = true } = {}) {
+ async function resolve(input, { source = 'community', actor = null, create = true } = {}) {
const given = ALIAS_KINDS.map((kind) => ({ kind, value: input[kind], norm: normAlias(kind, input[kind]) })).filter((a) => a.norm);
const given = ALIAS_KINDS.map((kind) => ({ kind, value: input[kind], norm: normAlias(kind, input[kind]) })).filter((a) => a.norm);
if (input.gtin && !normAlias('gtin', input.gtin)) throw new ApiError(422, 'request.invalid', 'gtin must be 8 to 14 digits');
if (input.gtin && !normAlias('gtin', input.gtin)) throw new ApiError(422, 'request.invalid', 'gtin must be 8 to 14 digits');
let product = null;
let product = null;
for (const a of given) {
for (const a of given) {
- const hit = q.alias.get(a.kind, a.norm);
- if (hit) { product = q.productById.get(hit.product_id); break; }
+ const hit = await q.alias.get(a.kind, a.norm);
+ if (hit) { product = await q.productById.get(hit.product_id); break; }
}
}
let created = false;
let created = false;
if (!product) {
if (!product) {
if (!create) return { product: null, created: false, conflicts: [] };
if (!create) return { product: null, created: false, conflicts: [] };
const name = text(input.name, { field: 'product name', min: 2, max: 200, required: true });
const name = text(input.name, { field: 'product name', min: 2, max: 200, required: true });
const id = newId('dpr');
const id = newId('dpr');
- q.insertProduct.run({
+ await q.insertProduct.run({
id, slug: slugify(name, id), name, brand: text(input.brand, { max: 120 }), category: text(input.category, { max: 60 }),
id, slug: slugify(name, id), name, brand: text(input.brand, { max: 120 }), category: text(input.category, { max: 60 }),
description: longText(input.description, 2000), created_by: actor, now: store.now(),
description: longText(input.description, 2000), created_by: actor, now: store.now(),
});
});
- product = q.productById.get(id);
+ product = await q.productById.get(id);
created = true;
created = true;
}
}
const conflicts = [];
const conflicts = [];
for (const a of given) {
for (const a of given) {
- const hit = q.alias.get(a.kind, a.norm);
+ const hit = await q.alias.get(a.kind, a.norm);
if (hit && hit.product_id !== product.id) { conflicts.push({ kind: a.kind, value: a.value, product_id: hit.product_id }); continue; }
if (hit && hit.product_id !== product.id) { conflicts.push({ kind: a.kind, value: a.value, product_id: hit.product_id }); continue; }
- if (!hit) q.insertAlias.run(newId('dpa'), product.id, a.kind, String(a.value).trim().slice(0, 300), a.norm, source, actor, store.now());
+ if (!hit) await q.insertAlias.run(newId('dpa'), product.id, a.kind, String(a.value).trim().slice(0, 300), a.norm, source, actor, store.now());
}
}
return { product, created, conflicts };
return { product, created, conflicts };
}
}
@@ -89,11 +89,11 @@ function createCatalog({ store }) {
@@ -89,11 +89,11 @@ function createCatalog({ store }) {
return {
return {
ensureStore,
ensureStore,
storeLabel,
storeLabel,
- store: (id) => (id ? q.storeById.get(id) : null),
- storeByDomain: (d) => q.storeByDomain.get(String(d || '').toLowerCase()),
- product: (id) => (id ? q.productById.get(id) : null),
- productBySlug: (slug) => q.productBySlug.get(String(slug || '')),
- aliases: (productId) => q.aliases.all(productId),
+ store: async (id) => (id ? await q.storeById.get(id) : null),
+ storeByDomain: async (d) => await q.storeByDomain.get(String(d || '').toLowerCase()),
+ product: async (id) => (id ? await q.productById.get(id) : null),
+ productBySlug: async (slug) => await q.productBySlug.get(String(slug || '')),
+ aliases: async (productId) => await q.aliases.all(productId),
resolve,
resolve,
normAlias,
normAlias,
};
};
@@ -20,73 +20,73 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
@@ -20,73 +20,73 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
VALUES (@id, @offer_id, @kind, @origin, @reporter, @reason, @details, @dedupe_key, 'open', @now, @now)`),
VALUES (@id, @offer_id, @kind, @origin, @reporter, @reason, @details, @dedupe_key, 'open', @now, @now)`),
touch: db.prepare('UPDATE deal_flags SET details = ?, reason = COALESCE(?, reason), updated_at = ? WHERE id = ?'),
touch: db.prepare('UPDATE deal_flags SET details = ?, reason = COALESCE(?, reason), updated_at = ? WHERE id = ?'),
list: db.prepare('SELECT * FROM deal_flags WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'),
list: db.prepare('SELECT * FROM deal_flags WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'),
- forOffer: db.prepare('SELECT * FROM deal_flags WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY created_at DESC'),
+ forOffer: db.prepare('SELECT * FROM deal_flags WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY created_at DESC'),
resolve: db.prepare('UPDATE deal_flags SET status = ?, resolved_by = ?, resolved_at = ?, resolution = ?, updated_at = ? WHERE id = ?'),
resolve: db.prepare('UPDATE deal_flags SET status = ?, resolved_by = ?, resolved_at = ?, resolution = ?, updated_at = ? WHERE id = ?'),
};
};
/** A person's report. */
/** A person's report. */
- function report(viewer, idOrSlug, input = {}, { ip = null } = {}) {
+ async function report(viewer, idOrSlug, input = {}, { ip = null } = {}) {
const subject = viewer && viewer.subject;
const subject = viewer && viewer.subject;
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a deal');
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a deal');
- const root = reads.root(reads.mustFind(idOrSlug));
+ const root = await reads.root(await reads.mustFind(idOrSlug));
const kind = String(input.kind || '');
const kind = String(input.kind || '');
if (!USER_KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${USER_KINDS.join(', ')}`);
if (!USER_KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${USER_KINDS.join(', ')}`);
const reason = text(input.reason, { field: 'reason', max: 500 });
const reason = text(input.reason, { field: 'reason', max: 500 });
let details = {};
let details = {};
if (kind === 'duplicate') {
if (kind === 'duplicate') {
- const other = input.duplicate_of ? reads.find(String(input.duplicate_of).trim().replace(/^.*\/d\//, '').replace(/(\.json)?([?#].*)?$/, '')) : null;
+ const other = input.duplicate_of ? await reads.find(String(input.duplicate_of).trim().replace(/^.*\/d\//, '').replace(/(\.json)?([?#].*)?$/, '')) : null;
if (!other) throw new ApiError(422, 'request.invalid', 'duplicate_of must name the other deal (id or slug)');
if (!other) throw new ApiError(422, 'request.invalid', 'duplicate_of must name the other deal (id or slug)');
- details = { duplicate_of: reads.root(other).id };
+ details = { duplicate_of: (await reads.root(other)).id };
}
}
- return store.tx(() => {
+ return await store.tx(async () => {
const key = `user:${subject}:${root.id}`;
const key = `user:${subject}:${root.id}`;
- const open = q.openByKey.get(key);
+ const open = await q.openByKey.get(key);
if (open) {
if (open) {
- q.touch.run(JSON.stringify({ ...JSON.parse(open.details || '{}'), ...details, kind }), reason, store.now(), open.id);
- return { flag: q.get.get(open.id), created: false };
+ await q.touch.run(JSON.stringify({ ...JSON.parse(open.details || '{}'), ...details, kind }), reason, store.now(), open.id);
+ return { flag: await q.get.get(open.id), created: false };
}
}
- limits.check('flag', subject, config.abuse.flagPerDay, 24 * 3600 * 1000, 'flag.rate_limited');
+ await limits.check('flag', subject, config.abuse.flagPerDay, 24 * 3600 * 1000, 'flag.rate_limited');
const id = newId('dfl');
const id = newId('dfl');
- q.insert.run({ id, offer_id: root.id, kind, origin: 'user', reporter: subject, reason, details: JSON.stringify(details), dedupe_key: key, now: store.now() });
- limits.hit('flag', subject);
- if (ip) limits.hit('flag_ip', ip);
- return { flag: q.get.get(id), created: true };
+ await q.insert.run({ id, offer_id: root.id, kind, origin: 'user', reporter: subject, reason, details: JSON.stringify(details), dedupe_key: key, now: store.now() });
+ await limits.hit('flag', subject);
+ if (ip) await limits.hit('flag_ip', ip);
+ return { flag: await q.get.get(id), created: true };
});
});
}
}
/** A system flag (inside the caller's transaction): opened once per key, details refreshed. */
/** A system flag (inside the caller's transaction): opened once per key, details refreshed. */
- function raise({ kind, offerId, key, reason, details }) {
- const open = q.openByKey.get(key);
- if (open) { q.touch.run(JSON.stringify(details || {}), reason || null, store.now(), open.id); return { flag: q.get.get(open.id), created: false }; }
+ async function raise({ kind, offerId, key, reason, details }) {
+ const open = await q.openByKey.get(key);
+ if (open) { await q.touch.run(JSON.stringify(details || {}), reason || null, store.now(), open.id); return { flag: await q.get.get(open.id), created: false }; }
const id = newId('dfl');
const id = newId('dfl');
- q.insert.run({ id, offer_id: offerId, kind, origin: 'system', reporter: null, reason: reason || null, details: JSON.stringify(details || {}), dedupe_key: key, now: store.now() });
- return { flag: q.get.get(id), created: true };
+ await q.insert.run({ id, offer_id: offerId, kind, origin: 'system', reporter: null, reason: reason || null, details: JSON.stringify(details || {}), dedupe_key: key, now: store.now() });
+ return { flag: await q.get.get(id), created: true };
}
}
- function resolve(viewer, flagId, { status = 'resolved', resolution } = {}, { traceparent } = {}) {
+ async function resolve(viewer, flagId, { status = 'resolved', resolution } = {}, { traceparent } = {}) {
if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');
if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');
- const f = q.get.get(String(flagId || ''));
+ const f = await q.get.get(String(flagId || ''));
if (!f) throw new ApiError(404, 'flag.not_found', 'No such flag');
if (!f) throw new ApiError(404, 'flag.not_found', 'No such flag');
if (f.status !== 'open') return { flag: f, changed: false };
if (f.status !== 'open') return { flag: f, changed: false };
const actor = viewer.subject || viewer.service;
const actor = viewer.subject || viewer.service;
- return store.tx(() => {
- q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);
+ return await store.tx(async () => {
+ await q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);
logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });
logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });
// A moderator's decision on a report goes to Network's moderation audit log (ADR-022).
// A moderator's decision on a report goes to Network's moderation audit log (ADR-022).
if (outbox) {
if (outbox) {
const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
- outbox.moderationAction({
+ await outbox.moderationAction({
action: `flag.${status}`, target: { type: 'flag', id: f.id, owner_subject: f.reporter || null }, actorSubject: person,
action: `flag.${status}`, target: { type: 'flag', id: f.id, owner_subject: f.reporter || null }, actorSubject: person,
reason: text(resolution, { max: 300 }) || null, details: { offer_id: f.offer_id, kind: f.kind, origin: f.origin },
reason: text(resolution, { max: 300 }) || null, details: { offer_id: f.offer_id, kind: f.kind, origin: f.origin },
}, { traceparent });
}, { traceparent });
}
}
- return { flag: q.get.get(f.id), changed: true };
+ return { flag: await q.get.get(f.id), changed: true };
});
});
}
}
- function dto(f) {
- const offer = f.offer_id ? reads.get(f.offer_id) : null;
+ async function dto(f) {
+ const offer = f.offer_id ? await reads.get(f.offer_id) : null;
return {
return {
id: f.id, kind: f.kind, origin: f.origin, reporter: f.reporter, reason: f.reason, status: f.status,
id: f.id, kind: f.kind, origin: f.origin, reporter: f.reporter, reason: f.reason, status: f.status,
details: JSON.parse(f.details || '{}'), created_at: iso(f.created_at), updated_at: iso(f.updated_at),
details: JSON.parse(f.details || '{}'), created_at: iso(f.created_at), updated_at: iso(f.updated_at),
@@ -97,8 +97,8 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
@@ -97,8 +97,8 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
return {
return {
report, raise, resolve, dto, USER_KINDS,
report, raise, resolve, dto, USER_KINDS,
- list: ({ status = 'open', limit = 100, offset = 0 } = {}) => q.list.all(status, limit, offset),
- forOffer: (ids) => q.forOffer.all(JSON.stringify(ids)),
+ list: async ({ status = 'open', limit = 100, offset = 0 } = {}) => await q.list.all(status, limit, offset),
+ forOffer: async (ids) => await q.forOffer.all(JSON.stringify(ids)),
};
};
}
}
@@ -40,24 +40,24 @@ function createHotness({ store, reads }) {
@@ -40,24 +40,24 @@ function createHotness({ store, reads }) {
const { db } = store;
const { db } = store;
const insert = db.prepare(`INSERT INTO deal_hotness_snapshots (offer_id, computed_at, formula, up_count, down_count, up_weight, down_weight, score, hot, first_seen_at, reason)
const insert = db.prepare(`INSERT INTO deal_hotness_snapshots (offer_id, computed_at, formula, up_count, down_count, up_weight, down_weight, score, hot, first_seen_at, reason)
VALUES (@offer_id, @computed_at, @formula, @up_count, @down_count, @up_weight, @down_weight, @score, @hot, @first_seen_at, @reason)`);
VALUES (@offer_id, @computed_at, @formula, @up_count, @down_count, @up_weight, @down_weight, @score, @hot, @first_seen_at, @reason)`);
- const firstSeen = db.prepare('SELECT MIN(created_at) AS t FROM deal_offers WHERE id IN (SELECT value FROM json_each(?))');
+ const firstSeen = db.prepare('SELECT MIN(created_at) AS t FROM deal_offers WHERE id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb))');
/** Inputs for a canonical offer at time t (from the database, never from a request). */
/** Inputs for a canonical offer at time t (from the database, never from a request). */
- function inputs(rootId, t) {
- const ids = reads.groupIds(rootId);
- const tally = reads.tally(ids);
- return { ids, tally, firstSeenAt: firstSeen.get(JSON.stringify(ids)).t, t };
+ async function inputs(rootId, t) {
+ const ids = await reads.groupIds(rootId);
+ const tally = await reads.tally(ids);
+ return { ids, tally, firstSeenAt: (await firstSeen.get(JSON.stringify(ids))).t, t };
}
}
/** Compute and store a snapshot for a canonical offer. Inside the caller's transaction. */
/** Compute and store a snapshot for a canonical offer. Inside the caller's transaction. */
- function snapshot(rootId, reason, t = store.now()) {
- const { tally, firstSeenAt } = inputs(rootId, t);
+ async function snapshot(rootId, reason, t = store.now()) {
+ const { tally, firstSeenAt } = await inputs(rootId, t);
const r = formula({ upWeight: tally.upWeight, downWeight: tally.downWeight, firstSeenAt, t });
const r = formula({ upWeight: tally.upWeight, downWeight: tally.downWeight, firstSeenAt, t });
const row = {
const row = {
offer_id: rootId, computed_at: t, formula: r.formula, up_count: tally.up, down_count: tally.down,
offer_id: rootId, computed_at: t, formula: r.formula, up_count: tally.up, down_count: tally.down,
up_weight: tally.upWeight, down_weight: tally.downWeight, score: r.score, hot: r.hot, first_seen_at: firstSeenAt, reason,
up_weight: tally.upWeight, down_weight: tally.downWeight, score: r.score, hot: r.hot, first_seen_at: firstSeenAt, reason,
};
};
- insert.run(row);
+ await insert.run(row);
return row;
return row;
}
}
@@ -67,11 +67,11 @@ function createHotness({ store, reads }) {
@@ -67,11 +67,11 @@ function createHotness({ store, reads }) {
}
}
/** Worker: snapshot every active canonical offer created in the window, all at the same t. */
/** Worker: snapshot every active canonical offer created in the window, all at the same t. */
- function tick({ windowDays = 14, t = store.now() } = {}) {
- const ids = db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?`).all(t - windowDays * 24 * HOUR).map((r) => r.id);
- store.tx(() => { for (const id of ids) snapshot(id, 'tick', t); });
+ async function tick({ windowDays = 14, t = store.now() } = {}) {
+ const ids = (await db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);
+ await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });
// Keep the latest snapshot per offer and everything from the last 7 days.
// Keep the latest snapshot per offer and everything from the last 7 days.
- db.prepare(`DELETE FROM deal_hotness_snapshots WHERE computed_at < ? AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)`).run(t - 7 * 24 * HOUR);
+ await db.prepare(`DELETE FROM deal_hotness_snapshots WHERE computed_at < ? AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)`).run(t - 7 * 24 * HOUR);
return ids.length;
return ids.length;
}
}
@@ -47,14 +47,14 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -47,14 +47,14 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
JOIN deal_offers f ON f.id = s.offer_id
JOIN deal_offers f ON f.id = s.offer_id
JOIN deal_price_observations o ON o.source_id = s.id
JOIN deal_price_observations o ON o.source_id = s.id
WHERE s.kind = 'sources_item' AND s.removed_at IS NULL AND f.status = 'active'
WHERE s.kind = 'sources_item' AND s.removed_at IS NULL AND f.status = 'active'
- GROUP BY s.ref_id HAVING last < ? ORDER BY last LIMIT ?`),
+ GROUP BY s.ref_id HAVING MAX(o.observed_at) < ? ORDER BY last LIMIT ?`),
};
};
let lastError = null;
let lastError = null;
let lastRunAt = null;
let lastRunAt = null;
let running = null;
let running = null;
let kickTimer = null;
let kickTimer = null;
- const cursor = () => Number((q.getState.get(CURSOR_KEY) || {}).value || 0);
+ const cursor = async () => Number((await q.getState.get(CURSOR_KEY) || {}).value || 0);
function amount(v) { try { return parseAmount(v, 'price'); } catch { return null; } }
function amount(v) { try { return parseAmount(v, 'price'); } catch { return null; } }
function currencyOf(v) { return typeof v === 'string' && /^[A-Z]{3}$/.test(v) ? v : null; }
function currencyOf(v) { return typeof v === 'string' && /^[A-Z]{3}$/.test(v) ? v : null; }
@@ -102,30 +102,30 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -102,30 +102,30 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
return [];
return [];
}
}
- function removeItem(item) {
+ async function removeItem(item) {
const reason = (item.removed && item.removed.reason) || 'removed at the source';
const reason = (item.removed && item.removed.reason) || 'removed at the source';
- const rows = q.itemRows.all(item.id);
- for (const s of rows) q.removeSource.run(store.now(), reason, store.now(), s.id);
+ const rows = await q.itemRows.all(item.id);
+ for (const s of rows) await q.removeSource.run(store.now(), reason, store.now(), s.id);
const touched = [...new Set(rows.map((s) => s.offer_id))];
const touched = [...new Set(rows.map((s) => s.offer_id))];
for (const id of touched) {
for (const id of touched) {
- const offer = reads.get(id);
- if (offer.origin === 'import' && offer.status !== 'disabled' && q.liveSources.get(id).n === 0) {
+ const offer = await reads.get(id);
+ if (offer.origin === 'import' && offer.status !== 'disabled' && (await q.liveSources.get(id)).n === 0) {
const now = store.now();
const now = store.now();
- db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = 'svc:deals', updated_at = ? WHERE id = ?")
+ await db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = 'svc:deals', updated_at = ? WHERE id = ?")
.run(now, `source item removed: ${reason}`, now, id);
.run(now, `source item removed: ${reason}`, now, id);
- offers.logAction('disable', { offerId: id, actor: 'svc:deals', reason: `source item removed: ${reason}`, before: { status: offer.status }, after: { status: 'disabled' } });
+ await offers.logAction('disable', { offerId: id, actor: 'svc:deals', reason: `source item removed: ${reason}`, before: { status: offer.status }, after: { status: 'disabled' } });
}
}
- const after = reads.get(id);
- indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed: ['sources'], source_removed: item.id } });
- indexing.reindex(after);
+ const after = await reads.get(id);
+ await indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed: ['sources'], source_removed: item.id } });
+ await indexing.reindex(after);
}
}
return touched.length ? 'removed' : 'removed:unknown';
return touched.length ? 'removed' : 'removed:unknown';
}
}
/** One item, inside a transaction. Returns what happened (for logs and tests). */
/** One item, inside a transaction. Returns what happened (for logs and tests). */
- function importItem(item) {
+ async function importItem(item) {
if (!item || item.category !== 'deals') return 'skipped:category';
if (!item || item.category !== 'deals') return 'skipped:category';
- if (item.removed) return removeItem(item);
+ if (item.removed) return await removeItem(item);
const retrievedAt = Date.parse(item.provenance && item.provenance.retrieved_at);
const retrievedAt = Date.parse(item.provenance && item.provenance.retrieved_at);
if (!Number.isFinite(retrievedAt)) return 'skipped:no_observation_time';
if (!Number.isFinite(retrievedAt)) return 'skipped:no_observation_time';
const list = candidates(item);
const list = candidates(item);
@@ -137,42 +137,42 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -137,42 +137,42 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
source_key: item.source_key, url: item.canonical_url, label: item.title,
source_key: item.source_key, url: item.canonical_url, label: item.title,
license_note: item.provenance.license_note || null, retrieved_at: retrievedAt,
license_note: item.provenance.license_note || null, retrieved_at: retrievedAt,
};
};
- const existing = q.sourceRow.get(item.id, c.part);
+ const existing = await q.sourceRow.get(item.id, c.part);
if (existing) {
if (existing) {
const advanced = item.revision > (existing.ref_revision || 0) || retrievedAt > (existing.retrieved_at || 0);
const advanced = item.revision > (existing.ref_revision || 0) || retrievedAt > (existing.retrieved_at || 0);
if (!advanced || existing.removed_at) { outcomes.push('unchanged'); continue; }
if (!advanced || existing.removed_at) { outcomes.push('unchanged'); continue; }
const origin = item.revision > (existing.ref_revision || 0) ? 'import' : 'import_refresh';
const origin = item.revision > (existing.ref_revision || 0) ? 'import' : 'import_refresh';
- q.bumpSource.run(Math.max(item.revision, existing.ref_revision || 0), Math.max(retrievedAt, existing.retrieved_at || 0), item.title, store.now(), existing.id);
- const obs = offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });
- const offer = reads.get(existing.offer_id);
+ await q.bumpSource.run(Math.max(item.revision, existing.ref_revision || 0), Math.max(retrievedAt, existing.retrieved_at || 0), item.title, store.now(), existing.id);
+ const obs = await offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });
+ const offer = await reads.get(existing.offer_id);
const changed = ['observation'];
const changed = ['observation'];
if (origin === 'import' && c.expires_at && offer.status === 'active' && offer.origin === 'import' && c.expires_at !== offer.expires_at) {
if (origin === 'import' && c.expires_at && offer.status === 'active' && offer.origin === 'import' && c.expires_at !== offer.expires_at) {
- db.prepare('UPDATE deal_offers SET expires_at = ?, updated_at = ? WHERE id = ?').run(c.expires_at, store.now(), offer.id);
+ await db.prepare('UPDATE deal_offers SET expires_at = ?, updated_at = ? WHERE id = ?').run(c.expires_at, store.now(), offer.id);
changed.push('expires_at');
changed.push('expires_at');
}
}
// Anything else the observation moved on the offer (price, product) is named too, so consumers never miss a field.
// Anything else the observation moved on the offer (price, product) is named too, so consumers never miss a field.
- const after = reads.get(offer.id);
+ const after = await reads.get(offer.id);
for (const k of ['product_id', 'title', 'status']) if (after && offer && after[k] !== offer[k] && !changed.includes(k)) changed.push(k);
for (const k of ['product_id', 'title', 'status']) if (after && offer && after[k] !== offer[k] && !changed.includes(k)) changed.push(k);
- indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed, observation_id: obs.id } });
- indexing.reindex(reads.get(offer.id));
+ await indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed, observation_id: obs.id } });
+ await indexing.reindex(await reads.get(offer.id));
outcomes.push(origin === 'import' ? 'updated' : 'refreshed');
outcomes.push(origin === 'import' ? 'updated' : 'refreshed');
continue;
continue;
}
}
let productId = null;
let productId = null;
if (c.product) {
if (c.product) {
- try { productId = catalog.resolve(c.product, { source: 'import', actor: 'svc:deals' }).product.id; } catch { productId = null; }
+ try { productId = (await catalog.resolve(c.product, { source: 'import', actor: 'svc:deals' })).product.id; } catch { productId = null; }
}
}
- const dup = offers.findByUrl(c.url);
+ const dup = await offers.findByUrl(c.url);
if (dup) {
if (dup) {
- const s = offers.ensureSource(dup.id, src);
- const obs = offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });
- if (productId && !dup.product_id) db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);
- indexing.emitOffer('deals.offer.updated', reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });
- indexing.reindex(reads.get(dup.id));
+ const s = await offers.ensureSource(dup.id, src);
+ const obs = await offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });
+ if (productId && !dup.product_id) await db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);
+ await indexing.emitOffer('deals.offer.updated', await reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });
+ await indexing.reindex(await reads.get(dup.id));
outcomes.push('attached');
outcomes.push('attached');
continue;
continue;
}
}
- offers.createImported({
+ await offers.createImported({
url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId,
url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId,
store_name: c.store_name, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,
store_name: c.store_name, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,
}, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });
}, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });
@@ -181,7 +181,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -181,7 +181,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
return outcomes.join(',');
return outcomes.join(',');
}
}
- const importOne = db.transaction((item) => importItem(item));
+ const importOne = async (item) => await db.tx(async () => await importItem(item));
/** Pull the change feed from the cursor. Never throws; the cursor only moves past applied items. */
/** Pull the change feed from the cursor. Never throws; the cursor only moves past applied items. */
async function pull() {
async function pull() {
@@ -191,18 +191,18 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -191,18 +191,18 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
const summary = { pages: 0, items: 0, outcomes: {} };
const summary = { pages: 0, items: 0, outcomes: {} };
try {
try {
for (let p = 0; p < config.sources.maxPages; p++) {
for (let p = 0; p < config.sources.maxPages; p++) {
- const after = cursor();
+ const after = await cursor();
const page = await sources.items({ after, limit: config.sources.pageSize });
const page = await sources.items({ after, limit: config.sources.pageSize });
summary.pages++;
summary.pages++;
- store.tx(() => {
+ await store.tx(async () => {
for (const item of page.items || []) {
for (const item of page.items || []) {
let out;
let out;
// Each item in its own savepoint: one unreadable item is reported, not a stuck cursor.
// Each item in its own savepoint: one unreadable item is reported, not a stuck cursor.
- try { out = importOne(item); } catch (err) { out = 'failed'; log.warn(`[Deals] import of ${item && item.id} failed: ${err.message}`); }
+ try { out = await importOne(item); } catch (err) { out = 'failed'; log.warn(`[Deals] import of ${item && item.id} failed: ${err.message}`); }
summary.items++;
summary.items++;
for (const o of out.split(',')) summary.outcomes[o] = (summary.outcomes[o] || 0) + 1;
for (const o of out.split(',')) summary.outcomes[o] = (summary.outcomes[o] || 0) + 1;
}
}
- if (Number.isFinite(page.next_after) && page.next_after > after) q.setState.run(CURSOR_KEY, String(page.next_after), store.now());
+ if (Number.isFinite(page.next_after) && page.next_after > after) await q.setState.run(CURSOR_KEY, String(page.next_after), store.now());
});
});
if (!page.more) break;
if (!page.more) break;
}
}
@@ -226,13 +226,13 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -226,13 +226,13 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
*/
*/
async function refresh() {
async function refresh() {
if (!sources.enabled) return { skipped: 'import off' };
if (!sources.enabled) return { skipped: 'import off' };
- const due = q.refreshDue.all(store.now() - config.freshnessMs / 2, config.sources.refreshBatch);
+ const due = await q.refreshDue.all(store.now() - config.freshnessMs / 2, config.sources.refreshBatch);
const out = { checked: 0, outcomes: {} };
const out = { checked: 0, outcomes: {} };
for (const { ref_id: id } of due) {
for (const { ref_id: id } of due) {
try {
try {
const data = await sources.item(id);
const data = await sources.item(id);
out.checked++;
out.checked++;
- const o = store.tx(() => importItem(data.item));
+ const o = await store.tx(async () => await importItem(data.item));
for (const x of o.split(',')) out.outcomes[x] = (out.outcomes[x] || 0) + 1;
for (const x of o.split(',')) out.outcomes[x] = (out.outcomes[x] || 0) + 1;
} catch (err) {
} catch (err) {
if (err.status === 404) continue;
if (err.status === 404) continue;
@@ -251,7 +251,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
@@ -251,7 +251,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
return {
return {
pull, refresh, kick, importItem, cursor,
pull, refresh, kick, importItem, cursor,
- status: () => ({ enabled: sources.enabled, cursor: cursor(), last_error: lastError, last_run_at: iso(lastRunAt) }),
+ status: async () => ({ enabled: sources.enabled, cursor: await cursor(), last_error: lastError, last_run_at: iso(lastRunAt) }),
};
};
}
}
@@ -23,17 +23,17 @@ function actorRef(actor) {
@@ -23,17 +23,17 @@ function actorRef(actor) {
}
}
function createIndexing({ store, publication, outbox, catalog }) {
function createIndexing({ store, publication, outbox, catalog }) {
- const { sequencer } = store;
+ const { sequencer, db } = store;
function latestPayload(v) {
function latestPayload(v) {
return v.latest ? { price: v.latest.price, currency: v.latest.currency, availability: v.latest.availability, observed_at: iso(v.latest.observed_at) } : null;
return v.latest ? { price: v.latest.price, currency: v.latest.currency, availability: v.latest.availability, observed_at: iso(v.latest.observed_at) } : null;
}
}
/** deals.offer.* for the canonical offer. */
/** deals.offer.* for the canonical offer. */
- function emitOffer(type, offer, { actor = null, extra = {}, traceparent } = {}) {
- const v = publication.offerView(offer);
+ async function emitOffer(type, offer, { actor = null, extra = {}, traceparent } = {}) {
+ const v = await publication.offerView(offer);
const r = v.root;
const r = v.root;
- return outbox.emit({
+ return await outbox.emit({
event_type: type,
event_type: type,
version: 1,
version: 1,
source: 'deals',
source: 'deals',
@@ -50,9 +50,9 @@ function createIndexing({ store, publication, outbox, catalog }) {
@@ -50,9 +50,9 @@ function createIndexing({ store, publication, outbox, catalog }) {
}, { traceparent });
}, { traceparent });
}
}
- function emitIfNew(doc, before) {
+ async function emitIfNew(doc, before) {
if (before != null && doc.revision === before) return null;
if (before != null && doc.revision === before) return null;
- return outbox.emit(hooks.indexEvent({ document: doc, now: store.now() }));
+ return await outbox.emit(hooks.indexEvent({ document: doc, now: store.now() }));
}
}
function offerDocument(v) {
function offerDocument(v) {
@@ -81,21 +81,21 @@ function createIndexing({ store, publication, outbox, catalog }) {
@@ -81,21 +81,21 @@ function createIndexing({ store, publication, outbox, catalog }) {
});
});
}
}
- function indexOffer(offer) {
+ async function indexOffer(offer) {
if (!offer) return null;
if (!offer) return null;
if (offer.merged_into) {
if (offer.merged_into) {
- const before = sequencer.current('deals', 'offer', offer.id);
- return emitIfNew(sequencer.stamp(hooks.tombstone({ owner: 'deals', type: 'offer', id: offer.id, revision: 0 })), before);
+ const before = await sequencer.current('deals', 'offer', offer.id);
+ return await emitIfNew(await sequencer.stamp(db, hooks.tombstone({ owner: 'deals', type: 'offer', id: offer.id, revision: 0 })), before);
}
}
- const v = publication.offerView(offer);
- const before = sequencer.current('deals', 'offer', v.root.id);
- return emitIfNew(sequencer.stamp(offerDocument(v)), before);
+ const v = await publication.offerView(offer);
+ const before = await sequencer.current('deals', 'offer', v.root.id);
+ return await emitIfNew(await sequencer.stamp(db, offerDocument(v)), before);
}
}
- function indexProduct(product) {
+ async function indexProduct(product) {
if (!product) return null;
if (!product) return null;
- const pv = publication.productView(product);
- const before = sequencer.current('deals', 'product', product.id);
+ const pv = await publication.productView(product);
+ const before = await sequencer.current('deals', 'product', product.id);
const lines = pv.offers.filter((v) => v.root.status === 'active').slice(0, 20).map((v) => `${v.root.title}${v.latest && v.latest.price != null ? ` — ${v.latest.price} ${v.latest.currency || ''} as of ${iso(v.latest.observed_at)}` : ''}`);
const lines = pv.offers.filter((v) => v.root.status === 'active').slice(0, 20).map((v) => `${v.root.title}${v.latest && v.latest.price != null ? ` — ${v.latest.price} ${v.latest.currency || ''} as of ${iso(v.latest.observed_at)}` : ''}`);
const doc = hooks.buildIndexDocument({
const doc = hooks.buildIndexDocument({
owner: 'deals', type: 'product', id: product.id, revision: 0, state: 'published', visibility: 'public',
owner: 'deals', type: 'product', id: product.id, revision: 0, state: 'published', visibility: 'public',
@@ -108,13 +108,13 @@ function createIndexing({ store, publication, outbox, catalog }) {
@@ -108,13 +108,13 @@ function createIndexing({ store, publication, outbox, catalog }) {
publishedAt: product.created_at,
publishedAt: product.created_at,
updatedAt: pv.freshestObservedAt || product.updated_at,
updatedAt: pv.freshestObservedAt || product.updated_at,
});
});
- return emitIfNew(sequencer.stamp(doc), before);
+ return await emitIfNew(await sequencer.stamp(db, doc), before);
}
}
/** After any change to an offer: its index document and its product's. */
/** After any change to an offer: its index document and its product's. */
- function reindex(offer) {
- indexOffer(offer);
- if (offer && offer.product_id) indexProduct(catalog.product(offer.product_id));
+ async function reindex(offer) {
+ await indexOffer(offer);
+ if (offer && offer.product_id) await indexProduct(await catalog.product(offer.product_id));
}
}
return { emitOffer, indexOffer, indexProduct, reindex, actorRef, offerDocument };
return { emitOffer, indexOffer, indexProduct, reindex, actorRef, offerDocument };
@@ -20,24 +20,24 @@ function createLimits({ config, store }) {
@@ -20,24 +20,24 @@ function createLimits({ config, store }) {
const insert = db.prepare('INSERT INTO rate_events (kind, key, at) VALUES (?, ?, ?)');
const insert = db.prepare('INSERT INTO rate_events (kind, key, at) VALUES (?, ?, ?)');
const prune = db.prepare('DELETE FROM rate_events WHERE at < ?');
const prune = db.prepare('DELETE FROM rate_events WHERE at < ?');
- function check(kind, key, max, windowMs, code = 'rate.limited') {
+ async function check(kind, key, max, windowMs, code = 'rate.limited') {
if (!key || !(max > 0)) return;
if (!key || !(max > 0)) return;
const since = store.now() - windowMs;
const since = store.now() - windowMs;
- const r = count.get(kind, key, since);
+ const r = await count.get(kind, key, since);
if (r.n >= max) {
if (r.n >= max) {
const retry = Math.max(1, Math.ceil((r.oldest + windowMs - store.now()) / 1000));
const retry = Math.max(1, Math.ceil((r.oldest + windowMs - store.now()) / 1000));
throw new ApiError(429, code, `Too many ${kind.replace(/_/g, ' ')} actions; try again in ${retry} s`, { retry_after: retry });
throw new ApiError(429, code, `Too many ${kind.replace(/_/g, ' ')} actions; try again in ${retry} s`, { retry_after: retry });
}
}
}
}
- function hit(kind, key) { if (key) insert.run(kind, key, store.now()); }
+ async function hit(kind, key) { if (key) await insert.run(kind, key, store.now()); }
function ipHash(ip) {
function ipHash(ip) {
if (!ip) return null;
if (!ip) return null;
return crypto.createHmac('sha256', config.ipHashSecret || fallbackKey).update(`deals-ip:${ip}`).digest('base64url').slice(0, 22);
return crypto.createHmac('sha256', config.ipHashSecret || fallbackKey).update(`deals-ip:${ip}`).digest('base64url').slice(0, 22);
}
}
- return { check, hit, ipHash, prune: (olderThanMs = 3 * 24 * 3600 * 1000) => prune.run(store.now() - olderThanMs).changes, stableIpHash: Boolean(config.ipHashSecret) };
+ return { check, hit, ipHash, prune: async (olderThanMs = 3 * 24 * 3600 * 1000) => (await prune.run(store.now() - olderThanMs)).changes, stableIpHash: Boolean(config.ipHashSecret) };
}
}
module.exports = { createLimits };
module.exports = { createLimits };
@@ -15,8 +15,8 @@ function createListings({ store }) {
@@ -15,8 +15,8 @@ function createListings({ store }) {
const LATEST = 'h.id = (SELECT MAX(id) FROM deal_hotness_snapshots WHERE offer_id = o.id)';
const LATEST = 'h.id = (SELECT MAX(id) FROM deal_hotness_snapshots WHERE offer_id = o.id)';
const q = {
const q = {
hot: db.prepare(`SELECT o.*, h.hot AS hot FROM deal_offers o LEFT JOIN deal_hotness_snapshots h ON ${LATEST}
hot: db.prepare(`SELECT o.*, h.hot AS hot FROM deal_offers o LEFT JOIN deal_hotness_snapshots h ON ${LATEST}
- WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY COALESCE(h.hot, 0) DESC, o.created_at DESC, o.rowid DESC LIMIT ? OFFSET ?`),
- newest: db.prepare(`SELECT o.* FROM deal_offers o WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY o.created_at DESC, o.rowid DESC LIMIT ? OFFSET ?`),
+ WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY COALESCE(h.hot, 0) DESC, o.created_at DESC, o.seq DESC LIMIT ? OFFSET ?`),
+ newest: db.prepare(`SELECT o.* FROM deal_offers o WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY o.created_at DESC, o.seq DESC LIMIT ? OFFSET ?`),
countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),
countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),
byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),
countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),
@@ -30,28 +30,28 @@ function createListings({ store }) {
@@ -30,28 +30,28 @@ function createListings({ store }) {
pendingReview: db.prepare(`SELECT * FROM deal_offers WHERE review_state = 'pending' AND merged_into IS NULL AND status <> 'disabled' ORDER BY created_at DESC LIMIT ?`),
pendingReview: db.prepare(`SELECT * FROM deal_offers WHERE review_state = 'pending' AND merged_into IS NULL AND status <> 'disabled' ORDER BY created_at DESC LIMIT ?`),
};
};
- function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {
+ async function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {
const words = [...new Set(tokens(query))].slice(0, 6);
const words = [...new Set(tokens(query))].slice(0, 6);
if (!words.length) return { total: 0, rows: [] };
if (!words.length) return { total: 0, rows: [] };
const where = words.map((_, i) => `(' ' || lower(o.title) || ' ' || lower(COALESCE(o.description, '')) || ' ') LIKE @w${i}`).join(' AND ');
const where = words.map((_, i) => `(' ' || lower(o.title) || ' ' || lower(COALESCE(o.description, '')) || ' ') LIKE @w${i}`).join(' AND ');
const params = Object.fromEntries(words.map((w, i) => [`w${i}`, `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`]));
const params = Object.fromEntries(words.map((w, i) => [`w${i}`, `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`]));
const status = includeExpired ? "o.status <> 'disabled'" : "o.status = 'active'";
const status = includeExpired ? "o.status <> 'disabled'" : "o.status = 'active'";
const base = `FROM deal_offers o WHERE o.merged_into IS NULL AND ${status} AND ${where.replace(/LIKE (@w\d+)/g, "LIKE $1 ESCAPE '\\'")}`;
const base = `FROM deal_offers o WHERE o.merged_into IS NULL AND ${status} AND ${where.replace(/LIKE (@w\d+)/g, "LIKE $1 ESCAPE '\\'")}`;
- const total = db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params).n;
- const rows = db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
+ const total = (await db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params)).n;
+ const rows = await db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
return { total, rows, words };
return { total, rows, words };
}
}
return {
return {
- hot: (limit, offset) => q.hot.all(limit, offset),
- newest: (limit, offset) => q.newest.all(limit, offset),
- countActive: () => q.countActive.get().n,
- byStore: (storeId, limit, offset) => q.byStore.all(storeId, limit, offset),
- countStore: (storeId) => q.countStore.get(storeId).n,
- indexable: () => q.indexable.all(),
- products: () => q.products.all(),
- possibleDuplicates: (limit = 50) => q.duplicates.all(limit),
- pendingReview: (limit = 50) => q.pendingReview.all(limit),
+ hot: async (limit, offset) => await q.hot.all(limit, offset),
+ newest: async (limit, offset) => await q.newest.all(limit, offset),
+ countActive: async () => (await q.countActive.get()).n,
+ byStore: async (storeId, limit, offset) => await q.byStore.all(storeId, limit, offset),
+ countStore: async (storeId) => (await q.countStore.get(storeId)).n,
+ indexable: async () => await q.indexable.all(),
+ products: async () => await q.products.all(),
+ possibleDuplicates: async (limit = 50) => await q.duplicates.all(limit),
+ pendingReview: async (limit = 50) => await q.pendingReview.all(limit),
search,
search,
};
};
}
}
@@ -47,7 +47,7 @@ function createOffers(deps) {
@@ -47,7 +47,7 @@ function createOffers(deps) {
@shipping_num, @shipping_note, @condition, @availability, @origin, @observed_by, @source_revision, @note)`),
@shipping_num, @shipping_note, @condition, @availability, @origin, @observed_by, @source_revision, @note)`),
obs: db.prepare('SELECT * FROM deal_price_observations WHERE id = ?'),
obs: db.prepare('SELECT * FROM deal_price_observations WHERE id = ?'),
log: db.prepare('INSERT INTO moderation_log (action, offer_id, target_id, actor, reason, before, after, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'),
log: db.prepare('INSERT INTO moderation_log (action, offer_id, target_id, actor, reason, before, after, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'),
- logFor: db.prepare(`SELECT * FROM moderation_log WHERE offer_id IN (SELECT value FROM json_each(?)) OR target_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
+ logFor: db.prepare(`SELECT * FROM moderation_log WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) OR target_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY id`),
dueExpiry: db.prepare("SELECT * FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND expires_at IS NOT NULL AND expires_at <= ?"),
dueExpiry: db.prepare("SELECT * FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND expires_at IS NOT NULL AND expires_at <= ?"),
};
};
@@ -75,32 +75,32 @@ function createOffers(deps) {
@@ -75,32 +75,32 @@ function createOffers(deps) {
* to Network's moderation audit log (ADR-022), in the caller's transaction. The submitter (or a
* to Network's moderation audit log (ADR-022), in the caller's transaction. The submitter (or a
* service acting for them) acting on their own offer, and AI text deliveries, are not.
* service acting for them) acting on their own offer, and AI text deliveries, are not.
*/
*/
- function moderated(viewer, root, action, { reason = null, details = {}, traceparent } = {}) {
+ async function moderated(viewer, root, action, { reason = null, details = {}, traceparent } = {}) {
if (!outbox || !viewer || (viewer.kind === 'service' && viewer.origin === 'ai')) return null;
if (!outbox || !viewer || (viewer.kind === 'service' && viewer.origin === 'ai')) return null;
if (viewer.subject && viewer.subject === root.submitted_by) return null;
if (viewer.subject && viewer.subject === root.submitted_by) return null;
const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
- return outbox.moderationAction({ action, target: { type: 'offer', id: root.id, owner_subject: root.submitted_by || null }, actorSubject: person, reason, details }, { traceparent });
+ return await outbox.moderationAction({ action, target: { type: 'offer', id: root.id, owner_subject: root.submitted_by || null }, actorSubject: person, reason, details }, { traceparent });
}
}
- function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {
- q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());
+ async function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {
+ await q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());
}
}
- function ensureSource(offerId, s) {
+ async function ensureSource(offerId, s) {
const part = s.ref_part || '';
const part = s.ref_part || '';
- const existing = q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);
+ const existing = await q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);
if (existing) return existing;
if (existing) return existing;
const row = {
const row = {
id: newId('dos'), offer_id: offerId, kind: s.kind, ref_service: s.ref_service, ref_type: s.ref_type, ref_id: s.ref_id, ref_part: part,
id: newId('dos'), offer_id: offerId, kind: s.kind, ref_service: s.ref_service, ref_type: s.ref_type, ref_id: s.ref_id, ref_part: part,
ref_revision: s.ref_revision == null ? null : s.ref_revision, source_key: s.source_key || null, url: s.url || null, label: s.label || null,
ref_revision: s.ref_revision == null ? null : s.ref_revision, source_key: s.source_key || null, url: s.url || null, label: s.label || null,
license_note: s.license_note || null, submitted_by: s.submitted_by || null, retrieved_at: s.retrieved_at == null ? null : s.retrieved_at, now: store.now(),
license_note: s.license_note || null, submitted_by: s.submitted_by || null, retrieved_at: s.retrieved_at == null ? null : s.retrieved_at, now: store.now(),
};
};
- q.insertSource.run(row);
- return q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);
+ await q.insertSource.run(row);
+ return await q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);
}
}
/** Insert one observation and run the watch matcher. Inside the caller's transaction. */
/** Insert one observation and run the watch matcher. Inside the caller's transaction. */
- function recordObservation(offerId, sourceId, fields, { origin, observedBy = null, observedAt, sourceRevision = null }) {
+ async function recordObservation(offerId, sourceId, fields, { origin, observedBy = null, observedAt, sourceRevision = null }) {
const now = store.now();
const now = store.now();
if (!Number.isFinite(observedAt)) throw new ApiError(422, 'request.invalid', 'an observation needs the time it was observed');
if (!Number.isFinite(observedAt)) throw new ApiError(422, 'request.invalid', 'an observation needs the time it was observed');
if (observedAt > now + FUTURE_SKEW_MS) throw new ApiError(422, 'request.invalid', 'observed_at is in the future');
if (observedAt > now + FUTURE_SKEW_MS) throw new ApiError(422, 'request.invalid', 'observed_at is in the future');
@@ -110,32 +110,32 @@ function createOffers(deps) {
@@ -110,32 +110,32 @@ function createOffers(deps) {
shipping_note: fields.shipping_note || null, condition: fields.condition || null, availability: fields.availability || null,
shipping_note: fields.shipping_note || null, condition: fields.condition || null, availability: fields.availability || null,
origin, observed_by: observedBy, source_revision: sourceRevision, note: fields.note || null,
origin, observed_by: observedBy, source_revision: sourceRevision, note: fields.note || null,
};
};
- q.insertObs.run(row);
- const obs = q.obs.get(row.id);
- watches.onObservation(obs);
+ await q.insertObs.run(row);
+ const obs = await q.obs.get(row.id);
+ await watches.onObservation(obs);
return obs;
return obs;
}
}
- function insertOffer(f) {
+ async function insertOffer(f) {
const id = newId('dof');
const id = newId('dof');
- q.insertOffer.run({
+ await q.insertOffer.run({
id, slug: slugify(f.title, id), title: f.title, description: f.description || null, url: f.url, url_norm: urlKey(f.url),
id, slug: slugify(f.title, id), title: f.title, description: f.description || null, url: f.url, url_norm: urlKey(f.url),
store_id: f.store_id || null, product_id: f.product_id || null, category: f.category || null, origin: f.origin,
store_id: f.store_id || null, product_id: f.product_id || null, category: f.category || null, origin: f.origin,
submitted_by: f.submitted_by || null, text_origin: f.text_origin || 'human', review_state: f.review_state || 'not_required',
submitted_by: f.submitted_by || null, text_origin: f.text_origin || 'human', review_state: f.review_state || 'not_required',
ai_summary: f.ai_summary || null, expires_at: f.expires_at == null ? null : f.expires_at, now: store.now(),
ai_summary: f.ai_summary || null, expires_at: f.expires_at == null ? null : f.expires_at, now: store.now(),
});
});
- return reads.get(id);
+ return await reads.get(id);
}
}
- const findByUrl = (url) => { const hit = q.byUrl.get(urlKey(url)); return hit ? reads.root(hit) : null; };
+ const findByUrl = async (url) => { const hit = await q.byUrl.get(urlKey(url)); return hit ? await reads.root(hit) : null; };
- function mustRoot(idOrSlug) { return reads.root(reads.mustFind(idOrSlug)); }
+ async function mustRoot(idOrSlug) { return await reads.root(await reads.mustFind(idOrSlug)); }
- function tallies(root) { return reads.tally(reads.groupIds(root.id)); }
+ async function tallies(root) { return await reads.tally(await reads.groupIds(root.id)); }
// ── submission ──────────────────────────────────────────
// ── submission ──────────────────────────────────────────
- function submit(viewer, input = {}, { ip = null, traceparent } = {}) {
+ async function submit(viewer, input = {}, { ip = null, traceparent } = {}) {
const subject = viewer && viewer.subject;
const subject = viewer && viewer.subject;
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to submit a deal');
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to submit a deal');
const url = normalizeUrl(input.url);
const url = normalizeUrl(input.url);
@@ -146,55 +146,55 @@ function createOffers(deps) {
@@ -146,55 +146,55 @@ function createOffers(deps) {
const expiresAt = parseInstant(input.expires_at, 'expires_at');
const expiresAt = parseInstant(input.expires_at, 'expires_at');
if (expiresAt != null && expiresAt <= store.now()) throw new ApiError(422, 'request.invalid', 'expires_at is already in the past');
if (expiresAt != null && expiresAt <= store.now()) throw new ApiError(422, 'request.invalid', 'expires_at is already in the past');
const ai = viewer.kind === 'service' && viewer.origin === 'ai';
const ai = viewer.kind === 'service' && viewer.origin === 'ai';
- return store.tx(() => {
- limits.check('submit', subject, config.abuse.submitPerDay, 24 * 3600 * 1000, 'submit.rate_limited');
- const dup = findByUrl(url);
+ return await store.tx(async () => {
+ await limits.check('submit', subject, config.abuse.submitPerDay, 24 * 3600 * 1000, 'submit.rate_limited');
+ const dup = await findByUrl(url);
if (dup) throw new ApiError(409, 'offer.duplicate', 'This link has already been posted', { existing: { id: dup.id, slug: dup.slug, url: publication.abs(publication.offerPath(dup)) } });
if (dup) throw new ApiError(409, 'offer.duplicate', 'This link has already been posted', { existing: { id: dup.id, slug: dup.slug, url: publication.abs(publication.offerPath(dup)) } });
- const st = catalog.ensureStore(domainOf(url), text(input.store_name, { max: 120 }));
+ const st = await catalog.ensureStore(domainOf(url), text(input.store_name, { max: 120 }));
let product = null;
let product = null;
const p = input.product && typeof input.product === 'object' ? input.product : null;
const p = input.product && typeof input.product === 'object' ? input.product : null;
if (input.product_slug) {
if (input.product_slug) {
- product = catalog.productBySlug(input.product_slug);
+ product = await catalog.productBySlug(input.product_slug);
if (!product) throw new ApiError(404, 'product.not_found', 'No such product');
if (!product) throw new ApiError(404, 'product.not_found', 'No such product');
} else if (p && (p.name || p.gtin || p.mpn || p.sku)) {
} else if (p && (p.name || p.gtin || p.mpn || p.sku)) {
- product = catalog.resolve(p, { source: 'community', actor: subject }).product;
+ product = (await catalog.resolve(p, { source: 'community', actor: subject })).product;
}
}
- const offer = insertOffer({
+ const offer = await insertOffer({
title, description, url, store_id: st ? st.id : null, product_id: product ? product.id : null,
title, description, url, store_id: st ? st.id : null, product_id: product ? product.id : null,
category: text(input.category, { max: 60 }), origin: 'community', submitted_by: subject,
category: text(input.category, { max: 60 }), origin: 'community', submitted_by: subject,
text_origin: ai ? 'ai' : 'human', review_state: ai ? 'pending' : 'not_required', expires_at: expiresAt,
text_origin: ai ? 'ai' : 'human', review_state: ai ? 'pending' : 'not_required', expires_at: expiresAt,
});
});
- const src = ensureSource(offer.id, { kind: 'submission', ref_service: 'deals', ref_type: 'submission', ref_id: offer.id, url, submitted_by: subject });
- limits.hit('submit', subject);
- if (ip) limits.hit('submit_ip', ip);
- hotness.snapshot(offer.id, 'create');
- recordObservation(offer.id, src.id, obs, { origin: 'community', observedBy: subject, observedAt: store.now() });
- indexing.emitOffer('deals.offer.created', offer, { actor: subject, traceparent });
- indexing.reindex(reads.get(offer.id));
- return { offer: reads.get(offer.id), created: true };
+ const src = await ensureSource(offer.id, { kind: 'submission', ref_service: 'deals', ref_type: 'submission', ref_id: offer.id, url, submitted_by: subject });
+ await limits.hit('submit', subject);
+ if (ip) await limits.hit('submit_ip', ip);
+ await hotness.snapshot(offer.id, 'create');
+ await recordObservation(offer.id, src.id, obs, { origin: 'community', observedBy: subject, observedAt: store.now() });
+ await indexing.emitOffer('deals.offer.created', offer, { actor: subject, traceparent });
+ await indexing.reindex(await reads.get(offer.id));
+ return { offer: await reads.get(offer.id), created: true };
});
});
}
}
/** Created by the Sources importer (no person); text is third-party and waits for review. */
/** Created by the Sources importer (no person); text is third-party and waits for review. */
- function createImported(f, source, obsFields, { observedAt, sourceRevision }) {
- const st = catalog.ensureStore(domainOf(f.url), f.store_name || null);
- const offer = insertOffer({
+ async function createImported(f, source, obsFields, { observedAt, sourceRevision }) {
+ const st = await catalog.ensureStore(domainOf(f.url), f.store_name || null);
+ const offer = await insertOffer({
title: f.title, description: f.description, url: f.url, store_id: st ? st.id : null, product_id: f.product_id || null,
title: f.title, description: f.description, url: f.url, store_id: st ? st.id : null, product_id: f.product_id || null,
category: f.category || null, origin: 'import', submitted_by: null, text_origin: 'imported', review_state: 'pending',
category: f.category || null, origin: 'import', submitted_by: null, text_origin: 'imported', review_state: 'pending',
expires_at: f.expires_at == null ? null : f.expires_at,
expires_at: f.expires_at == null ? null : f.expires_at,
});
});
- const src = ensureSource(offer.id, source);
- hotness.snapshot(offer.id, 'create');
- recordObservation(offer.id, src.id, obsFields, { origin: 'import', observedAt, sourceRevision });
- indexing.emitOffer('deals.offer.created', offer, { actor: 'svc:deals', extra: { imported_from: { service: 'sources', type: 'item', id: source.ref_id } } });
- indexing.reindex(reads.get(offer.id));
- return reads.get(offer.id);
+ const src = await ensureSource(offer.id, source);
+ await hotness.snapshot(offer.id, 'create');
+ await recordObservation(offer.id, src.id, obsFields, { origin: 'import', observedAt, sourceRevision });
+ await indexing.emitOffer('deals.offer.created', offer, { actor: 'svc:deals', extra: { imported_from: { service: 'sources', type: 'item', id: source.ref_id } } });
+ await indexing.reindex(await reads.get(offer.id));
+ return await reads.get(offer.id);
}
}
// ── edits and observations ──────────────────────────────
// ── edits and observations ──────────────────────────────
- function update(viewer, idOrSlug, input = {}, { traceparent } = {}) {
- const root = mustRoot(idOrSlug);
+ async function update(viewer, idOrSlug, input = {}, { traceparent } = {}) {
+ const root = await mustRoot(idOrSlug);
const actor = actorOf(viewer);
const actor = actorOf(viewer);
if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can edit it');
if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can edit it');
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
@@ -216,33 +216,33 @@ function createOffers(deps) {
@@ -216,33 +216,33 @@ function createOffers(deps) {
changes.text_origin = 'ai';
changes.text_origin = 'ai';
changes.review_state = 'pending';
changes.review_state = 'pending';
}
}
- return store.tx(() => {
+ return await store.tx(async () => {
if (input.product_slug !== undefined || input.product !== undefined) {
if (input.product_slug !== undefined || input.product !== undefined) {
if (!input.product_slug && !input.product) changes.product_id = null;
if (!input.product_slug && !input.product) changes.product_id = null;
else if (input.product_slug) {
else if (input.product_slug) {
- const p = catalog.productBySlug(input.product_slug);
+ const p = await catalog.productBySlug(input.product_slug);
if (!p) throw new ApiError(404, 'product.not_found', 'No such product');
if (!p) throw new ApiError(404, 'product.not_found', 'No such product');
changes.product_id = p.id;
changes.product_id = p.id;
- } else changes.product_id = catalog.resolve(input.product, { source: 'community', actor }).product.id;
+ } else changes.product_id = (await catalog.resolve(input.product, { source: 'community', actor })).product.id;
}
}
const keys = Object.keys(changes);
const keys = Object.keys(changes);
if (!keys.length) return { offer: root, changed: false };
if (!keys.length) return { offer: root, changed: false };
const oldProduct = root.product_id;
const oldProduct = root.product_id;
- db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });
- const after = reads.get(root.id);
- indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });
- moderated(viewer, root, 'offer.edited', { details: { changed: keys }, traceparent });
- indexing.reindex(after);
- if (oldProduct && oldProduct !== after.product_id) indexing.indexProduct(catalog.product(oldProduct));
+ await db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });
+ const after = await reads.get(root.id);
+ await indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });
+ await moderated(viewer, root, 'offer.edited', { details: { changed: keys }, traceparent });
+ await indexing.reindex(after);
+ if (oldProduct && oldProduct !== after.product_id) await indexing.indexProduct(await catalog.product(oldProduct));
return { offer: after, changed: true };
return { offer: after, changed: true };
});
});
}
}
/** A person (or a service acting for one) reports what they see at the link right now. */
/** A person (or a service acting for one) reports what they see at the link right now. */
- function observe(viewer, idOrSlug, input = {}, { ip = null, traceparent } = {}) {
+ async function observe(viewer, idOrSlug, input = {}, { ip = null, traceparent } = {}) {
const subject = viewer && viewer.subject;
const subject = viewer && viewer.subject;
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a price');
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a price');
- const root = mustRoot(idOrSlug);
+ const root = await mustRoot(idOrSlug);
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
const fields = parseObservation(input);
const fields = parseObservation(input);
if (fields.price == null && fields.shipping == null && !fields.availability && !fields.condition) {
if (fields.price == null && fields.shipping == null && !fields.availability && !fields.condition) {
@@ -250,47 +250,47 @@ function createOffers(deps) {
@@ -250,47 +250,47 @@ function createOffers(deps) {
}
}
let observedAt = store.now();
let observedAt = store.now();
if (viewer.kind === 'service' && input.observed_at != null) observedAt = parseInstant(input.observed_at, 'observed_at');
if (viewer.kind === 'service' && input.observed_at != null) observedAt = parseInstant(input.observed_at, 'observed_at');
- return store.tx(() => {
- limits.check('observe', subject, config.abuse.observePerHour, 3600 * 1000, 'observe.rate_limited');
- const src = ensureSource(root.id, { kind: 'community', ref_service: 'deals', ref_type: 'observer', ref_id: subject, ref_part: root.id, url: root.url, submitted_by: subject });
- const obs = recordObservation(root.id, src.id, fields, { origin: 'community', observedBy: subject, observedAt });
- limits.hit('observe', subject);
- if (ip) limits.hit('observe_ip', ip);
- db.prepare('UPDATE deal_offers SET updated_at = ? WHERE id = ?').run(store.now(), root.id);
- indexing.emitOffer('deals.offer.updated', root, { actor: subject, extra: { changed: ['observation'], observation_id: obs.id }, traceparent });
- indexing.reindex(reads.get(root.id));
- return { offer: reads.get(root.id), observation: obs };
+ return await store.tx(async () => {
+ await limits.check('observe', subject, config.abuse.observePerHour, 3600 * 1000, 'observe.rate_limited');
+ const src = await ensureSource(root.id, { kind: 'community', ref_service: 'deals', ref_type: 'observer', ref_id: subject, ref_part: root.id, url: root.url, submitted_by: subject });
+ const obs = await recordObservation(root.id, src.id, fields, { origin: 'community', observedBy: subject, observedAt });
+ await limits.hit('observe', subject);
+ if (ip) await limits.hit('observe_ip', ip);
+ await db.prepare('UPDATE deal_offers SET updated_at = ? WHERE id = ?').run(store.now(), root.id);
+ await indexing.emitOffer('deals.offer.updated', root, { actor: subject, extra: { changed: ['observation'], observation_id: obs.id }, traceparent });
+ await indexing.reindex(await reads.get(root.id));
+ return { offer: await reads.get(root.id), observation: obs };
});
});
}
}
// ── expiry and moderation ───────────────────────────────
// ── expiry and moderation ───────────────────────────────
- function expireRow(root, { actor, reason, traceparent }) {
+ async function expireRow(root, { actor, reason, traceparent }) {
const now = store.now();
const now = store.now();
- db.prepare("UPDATE deal_offers SET status = 'expired', expired_at = ?, expired_reason = ?, updated_at = ? WHERE id = ?").run(now, reason, now, root.id);
- logAction('expire', { offerId: root.id, actor: actor || 'svc:deals', reason, before: { status: root.status }, after: { status: 'expired' } });
- const after = reads.get(root.id);
- indexing.emitOffer('deals.offer.expired', after, { actor, extra: { reason }, traceparent });
- indexing.reindex(after);
+ await db.prepare("UPDATE deal_offers SET status = 'expired', expired_at = ?, expired_reason = ?, updated_at = ? WHERE id = ?").run(now, reason, now, root.id);
+ await logAction('expire', { offerId: root.id, actor: actor || 'svc:deals', reason, before: { status: root.status }, after: { status: 'expired' } });
+ const after = await reads.get(root.id);
+ await indexing.emitOffer('deals.offer.expired', after, { actor, extra: { reason }, traceparent });
+ await indexing.reindex(after);
return after;
return after;
}
}
- function expire(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
- const root = mustRoot(idOrSlug);
+ async function expire(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
+ const root = await mustRoot(idOrSlug);
if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');
if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');
if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);
if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);
const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';
const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';
- return store.tx(() => {
- const after = expireRow(root, { actor: actorOf(viewer), reason: why, traceparent });
- moderated(viewer, root, 'offer.expired', { reason: text(reason, { max: 200 }) || null, traceparent });
+ return await store.tx(async () => {
+ const after = await expireRow(root, { actor: actorOf(viewer), reason: why, traceparent });
+ await moderated(viewer, root, 'offer.expired', { reason: text(reason, { max: 200 }) || null, traceparent });
return after;
return after;
});
});
}
}
/** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */
/** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */
- function expireDue() {
- const due = q.dueExpiry.all(store.now());
- store.tx(() => { for (const o of due) expireRow(o, { actor: null, reason: 'stated_expiry' }); });
+ async function expireDue() {
+ const due = await q.dueExpiry.all(store.now());
+ await store.tx(async () => { for (const o of due) await expireRow(o, { actor: null, reason: 'stated_expiry' }); });
return due.length;
return due.length;
}
}
@@ -298,19 +298,19 @@ function createOffers(deps) {
@@ -298,19 +298,19 @@ function createOffers(deps) {
if (!access.isModerator(viewer, cap)) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
if (!access.isModerator(viewer, cap)) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
}
}
- function disable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
+ async function disable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
requireStaff(viewer, 'deals.offer.moderate');
requireStaff(viewer, 'deals.offer.moderate');
- const root = mustRoot(idOrSlug);
+ const root = await mustRoot(idOrSlug);
const why = text(reason, { field: 'reason', min: 3, max: 300, required: true });
const why = text(reason, { field: 'reason', min: 3, max: 300, required: true });
if (root.status === 'disabled') return { offer: root, changed: false };
if (root.status === 'disabled') return { offer: root, changed: false };
- const out = store.tx(() => {
+ const out = await store.tx(async () => {
const now = store.now();
const now = store.now();
- db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);
- logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });
- moderated(viewer, root, 'offer.disabled', { reason: why, details: { previous: root.status }, traceparent });
- const after = reads.get(root.id);
- indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });
- indexing.reindex(after);
+ await db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);
+ await logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });
+ await moderated(viewer, root, 'offer.disabled', { reason: why, details: { previous: root.status }, traceparent });
+ const after = await reads.get(root.id);
+ await indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });
+ await indexing.reindex(after);
return { offer: after, changed: true };
return { offer: after, changed: true };
});
});
threadVisibility(out.offer, 'hidden');
threadVisibility(out.offer, 'hidden');
@@ -322,21 +322,21 @@ function createOffers(deps) {
@@ -322,21 +322,21 @@ function createOffers(deps) {
if (community && community.enabled) community.setThreadVisibility(offer, visibility).catch(() => {});
if (community && community.enabled) community.setThreadVisibility(offer, visibility).catch(() => {});
}
}
- function enable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
+ async function enable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
requireStaff(viewer, 'deals.offer.moderate');
requireStaff(viewer, 'deals.offer.moderate');
- const root = mustRoot(idOrSlug);
+ const root = await mustRoot(idOrSlug);
if (root.status === 'active') return { offer: root, changed: false };
if (root.status === 'active') return { offer: root, changed: false };
const wasDisabled = root.status === 'disabled';
const wasDisabled = root.status === 'disabled';
- const out = store.tx(() => {
+ const out = await store.tx(async () => {
const now = store.now();
const now = store.now();
const keepExpiry = root.expires_at != null && root.expires_at > now ? root.expires_at : null;
const keepExpiry = root.expires_at != null && root.expires_at > now ? root.expires_at : null;
- db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,
+ await db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,
expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);
expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);
- logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });
- moderated(viewer, root, 'offer.enabled', { reason: text(reason, { max: 300 }) || null, details: { previous: root.status }, traceparent });
- const after = reads.get(root.id);
- indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });
- indexing.reindex(after);
+ await logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });
+ await moderated(viewer, root, 'offer.enabled', { reason: text(reason, { max: 300 }) || null, details: { previous: root.status }, traceparent });
+ const after = await reads.get(root.id);
+ await indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });
+ await indexing.reindex(after);
return { offer: after, changed: true };
return { offer: after, changed: true };
});
});
if (wasDisabled) threadVisibility(out.offer, 'public');
if (wasDisabled) threadVisibility(out.offer, 'public');
@@ -344,74 +344,74 @@ function createOffers(deps) {
@@ -344,74 +344,74 @@ function createOffers(deps) {
}
}
/** A person confirms imported or AI-assisted text (only a usr_ subject can review). */
/** A person confirms imported or AI-assisted text (only a usr_ subject can review). */
- function review(viewer, idOrSlug, { note } = {}, { traceparent } = {}) {
+ async function review(viewer, idOrSlug, { note } = {}, { traceparent } = {}) {
requireStaff(viewer, 'deals.offer.moderate');
requireStaff(viewer, 'deals.offer.moderate');
const reviewer = viewer.subject;
const reviewer = viewer.subject;
if (!reviewer) throw new ApiError(403, 'review.person_required', 'A review is recorded for a person (usr_…), never for a service');
if (!reviewer) throw new ApiError(403, 'review.person_required', 'A review is recorded for a person (usr_…), never for a service');
- const root = mustRoot(idOrSlug);
+ const root = await mustRoot(idOrSlug);
if (root.review_state !== 'pending') return { offer: root, changed: false };
if (root.review_state !== 'pending') return { offer: root, changed: false };
- return store.tx(() => {
+ return await store.tx(async () => {
const now = store.now();
const now = store.now();
- db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);
- logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });
- moderated(viewer, root, 'offer.reviewed', { reason: text(note, { max: 300 }) || null, traceparent });
- const after = reads.get(root.id);
- indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });
- indexing.reindex(after);
+ await db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);
+ await logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });
+ await moderated(viewer, root, 'offer.reviewed', { reason: text(note, { max: 300 }) || null, traceparent });
+ const after = await reads.get(root.id);
+ await indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });
+ await indexing.reindex(after);
return { offer: after, changed: true };
return { offer: after, changed: true };
});
});
}
}
- function merge(viewer, idOrSlug, intoIdOrSlug, { reason } = {}, { traceparent } = {}) {
+ async function merge(viewer, idOrSlug, intoIdOrSlug, { reason } = {}, { traceparent } = {}) {
requireStaff(viewer, 'deals.offer.merge');
requireStaff(viewer, 'deals.offer.merge');
- const dup = reads.mustFind(idOrSlug);
- const target = reads.root(reads.mustFind(intoIdOrSlug));
+ const dup = await reads.mustFind(idOrSlug);
+ const target = await reads.root(await reads.mustFind(intoIdOrSlug));
if (dup.merged_into) throw new ApiError(409, 'offer.already_merged', 'This offer is already merged; unmerge it first');
if (dup.merged_into) throw new ApiError(409, 'offer.already_merged', 'This offer is already merged; unmerge it first');
- if (reads.groupIds(dup.id).includes(target.id)) throw new ApiError(409, 'offer.merge_cycle', 'Cannot merge an offer into itself or into an offer merged into it');
+ if ((await reads.groupIds(dup.id)).includes(target.id)) throw new ApiError(409, 'offer.merge_cycle', 'Cannot merge an offer into itself or into an offer merged into it');
const actor = actorOf(viewer);
const actor = actorOf(viewer);
- return store.tx(() => {
- const before = { target: tallies(target), duplicate: tallies(dup) };
+ return await store.tx(async () => {
+ const before = { target: await tallies(target), duplicate: await tallies(dup) };
const now = store.now();
const now = store.now();
- db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);
- const after = { target: tallies(target) };
- logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });
- moderated(viewer, dup, 'offer.merged', { reason: text(reason, { max: 300 }) || null, details: { into: target.id }, traceparent });
- hotness.snapshot(target.id, 'merge');
- indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });
- indexing.indexOffer(reads.get(dup.id));
- indexing.reindex(reads.get(target.id));
- if (dup.product_id && dup.product_id !== target.product_id) indexing.indexProduct(catalog.product(dup.product_id));
- return { offer: reads.get(target.id), merged: reads.get(dup.id), before, after };
+ await db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);
+ const after = { target: await tallies(target) };
+ await logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });
+ await moderated(viewer, dup, 'offer.merged', { reason: text(reason, { max: 300 }) || null, details: { into: target.id }, traceparent });
+ await hotness.snapshot(target.id, 'merge');
+ await indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });
+ await indexing.indexOffer(await reads.get(dup.id));
+ await indexing.reindex(await reads.get(target.id));
+ if (dup.product_id && dup.product_id !== target.product_id) await indexing.indexProduct(await catalog.product(dup.product_id));
+ return { offer: await reads.get(target.id), merged: await reads.get(dup.id), before, after };
});
});
}
}
- function unmerge(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
+ async function unmerge(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
requireStaff(viewer, 'deals.offer.merge');
requireStaff(viewer, 'deals.offer.merge');
- const dup = reads.mustFind(idOrSlug);
+ const dup = await reads.mustFind(idOrSlug);
if (!dup.merged_into) throw new ApiError(409, 'offer.not_merged', 'This offer is not merged');
if (!dup.merged_into) throw new ApiError(409, 'offer.not_merged', 'This offer is not merged');
- const parentRoot = reads.root(dup);
+ const parentRoot = await reads.root(dup);
const actor = actorOf(viewer);
const actor = actorOf(viewer);
- return store.tx(() => {
- const before = { target: tallies(parentRoot) };
+ return await store.tx(async () => {
+ const before = { target: await tallies(parentRoot) };
const now = store.now();
const now = store.now();
- db.prepare('UPDATE deal_offers SET merged_into = NULL, merged_at = NULL, merged_by = NULL, updated_at = ? WHERE id = ?').run(now, dup.id);
- const restored = reads.get(dup.id);
- const after = { target: tallies(parentRoot), duplicate: tallies(restored) };
- logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });
- moderated(viewer, dup, 'offer.unmerged', { reason: text(reason, { max: 300 }) || null, details: { from: parentRoot.id }, traceparent });
- hotness.snapshot(parentRoot.id, 'unmerge');
- hotness.snapshot(restored.id, 'unmerge');
- indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });
- indexing.emitOffer('deals.offer.updated', restored, { actor, extra: { changed: ['unmerged'], unmerged_from: parentRoot.id }, traceparent });
- indexing.reindex(reads.get(parentRoot.id));
- indexing.reindex(restored);
- return { offer: restored, from: reads.get(parentRoot.id), before, after };
+ await db.prepare('UPDATE deal_offers SET merged_into = NULL, merged_at = NULL, merged_by = NULL, updated_at = ? WHERE id = ?').run(now, dup.id);
+ const restored = await reads.get(dup.id);
+ const after = { target: await tallies(parentRoot), duplicate: await tallies(restored) };
+ await logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });
+ await moderated(viewer, dup, 'offer.unmerged', { reason: text(reason, { max: 300 }) || null, details: { from: parentRoot.id }, traceparent });
+ await hotness.snapshot(parentRoot.id, 'unmerge');
+ await hotness.snapshot(restored.id, 'unmerge');
+ await indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });
+ await indexing.emitOffer('deals.offer.updated', restored, { actor, extra: { changed: ['unmerged'], unmerged_from: parentRoot.id }, traceparent });
+ await indexing.reindex(await reads.get(parentRoot.id));
+ await indexing.reindex(restored);
+ return { offer: restored, from: await reads.get(parentRoot.id), before, after };
});
});
}
}
- function moderationLog(root) {
- const ids = JSON.stringify(reads.groupIds(root.id));
- return q.logFor.all(ids, ids);
+ async function moderationLog(root) {
+ const ids = JSON.stringify(await reads.groupIds(root.id));
+ return await q.logFor.all(ids, ids);
}
}
return {
return {
@@ -59,22 +59,22 @@ function createPublication({ config, store, reads, catalog }) {
@@ -59,22 +59,22 @@ function createPublication({ config, store, reads, catalog }) {
}
}
/** Everything a page, the JSON twin, an event or the index needs about one canonical offer. */
/** Everything a page, the JSON twin, an event or the index needs about one canonical offer. */
- function offerView(offer, now = store.now()) {
- const root = reads.root(offer);
- const ids = reads.groupIds(root.id);
- const latest = reads.latestObservation(ids);
- const tally = reads.tally(ids);
- const snap = reads.lastSnapshot(root.id);
+ async function offerView(offer, now = store.now()) {
+ const root = await reads.root(offer);
+ const ids = await reads.groupIds(root.id);
+ const latest = await reads.latestObservation(ids);
+ const tally = await reads.tally(ids);
+ const snap = await reads.lastSnapshot(root.id);
return {
return {
root,
root,
ids,
ids,
- store: catalog.store(root.store_id),
- product: catalog.product(root.product_id),
- observations: reads.observations(ids),
+ store: await catalog.store(root.store_id),
+ product: await catalog.product(root.product_id),
+ observations: await reads.observations(ids),
latest,
latest,
freshness: freshness(latest, now),
freshness: freshness(latest, now),
- sources: reads.sources(ids),
- members: ids.filter((id) => id !== root.id).map((id) => reads.get(id)),
+ sources: await reads.sources(ids),
+ members: await Promise.all(ids.filter((id) => id !== root.id).map(async (id) => await reads.get(id))),
tally,
tally,
hotness: snap,
hotness: snap,
decision: decideOffer(root, latest, now),
decision: decideOffer(root, latest, now),
@@ -158,15 +158,15 @@ function createPublication({ config, store, reads, catalog }) {
@@ -158,15 +158,15 @@ function createPublication({ config, store, reads, catalog }) {
* price ascending), then fresh offers without a price, then stale / unobserved / expired ones —
* price ascending), then fresh offers without a price, then stale / unobserved / expired ones —
* an old or unknown price never sorts as if it were the price now, and unknown never sorts as 0.
* an old or unknown price never sorts as if it were the price now, and unknown never sorts as 0.
*/
*/
- function productView(product, now = store.now()) {
- const rows = productOfferIds.all(product.id).map((r) => offerView(reads.get(r.id), now));
+ async function productView(product, now = store.now()) {
+ const rows = await Promise.all((await productOfferIds.all(product.id)).map(async (r) => await offerView(await reads.get(r.id), now)));
const rank = (v) => (v.root.status !== 'active' ? 4 : v.freshness.state !== 'fresh' ? 3 : (v.latest && v.latest.price != null && v.latest.currency) ? 1 : 2);
const rank = (v) => (v.root.status !== 'active' ? 4 : v.freshness.state !== 'fresh' ? 3 : (v.latest && v.latest.price != null && v.latest.currency) ? 1 : 2);
rows.sort((a, b) => rank(a) - rank(b)
rows.sort((a, b) => rank(a) - rank(b)
|| (rank(a) === 1 ? (a.latest.currency < b.latest.currency ? -1 : a.latest.currency > b.latest.currency ? 1 : a.latest.price_num - b.latest.price_num) : 0)
|| (rank(a) === 1 ? (a.latest.currency < b.latest.currency ? -1 : a.latest.currency > b.latest.currency ? 1 : a.latest.price_num - b.latest.price_num) : 0)
|| b.root.created_at - a.root.created_at);
|| b.root.created_at - a.root.created_at);
const fresh = rows.filter((v) => v.root.status === 'active' && v.freshness.state === 'fresh').map((v) => v.latest.observed_at);
const fresh = rows.filter((v) => v.root.status === 'active' && v.freshness.state === 'fresh').map((v) => v.latest.observed_at);
const freshest = fresh.length ? Math.max(...fresh) : null;
const freshest = fresh.length ? Math.max(...fresh) : null;
- return { product, offers: rows, aliases: catalog.aliases(product.id), decision: decideProduct(product, freshest, now), freshestObservedAt: freshest };
+ return { product, offers: rows, aliases: await catalog.aliases(product.id), decision: decideProduct(product, freshest, now), freshestObservedAt: freshest };
}
}
function productJsonLd(pv) {
function productJsonLd(pv) {
@@ -15,58 +15,58 @@ function createReads({ store }) {
@@ -15,58 +15,58 @@ function createReads({ store }) {
const q = {
const q = {
byId: db.prepare('SELECT * FROM deal_offers WHERE id = ?'),
byId: db.prepare('SELECT * FROM deal_offers WHERE id = ?'),
bySlug: db.prepare('SELECT * FROM deal_offers WHERE slug = ?'),
bySlug: db.prepare('SELECT * FROM deal_offers WHERE slug = ?'),
- group: db.prepare(`WITH RECURSIVE g(id, depth) AS (SELECT ?, 0 UNION SELECT o.id, g.depth + 1 FROM deal_offers o JOIN g ON o.merged_into = g.id WHERE g.depth < 50)
+ group: db.prepare(`WITH RECURSIVE g(id, depth) AS (SELECT ?::text COLLATE "C", 0 UNION SELECT o.id, g.depth + 1 FROM deal_offers o JOIN g ON o.merged_into = g.id WHERE g.depth < 50)
SELECT id FROM g`),
SELECT id FROM g`),
- observations: db.prepare(`SELECT o.*, o.rowid AS seq, s.kind AS source_kind, s.ref_service, s.ref_type, s.ref_id, s.ref_revision, s.source_key,
+ observations: db.prepare(`SELECT o.*, s.kind AS source_kind, s.ref_service, s.ref_type, s.ref_id, s.ref_revision, s.source_key,
s.url AS source_url, s.label AS source_label, s.license_note, s.removed_at AS source_removed_at
s.url AS source_url, s.label AS source_label, s.license_note, s.removed_at AS source_removed_at
FROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id
FROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id
- WHERE o.offer_id IN (SELECT value FROM json_each(?))
- ORDER BY o.observed_at DESC, o.rowid DESC`),
+ WHERE o.offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb))
+ ORDER BY o.observed_at DESC, o.seq DESC`),
latest: db.prepare(`SELECT o.*, s.kind AS source_kind, s.ref_service, s.ref_id, s.source_key, s.url AS source_url, s.label AS source_label
latest: db.prepare(`SELECT o.*, s.kind AS source_kind, s.ref_service, s.ref_id, s.source_key, s.url AS source_url, s.label AS source_label
FROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id
FROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id
- WHERE o.offer_id IN (SELECT value FROM json_each(?)) AND s.removed_at IS NULL
- ORDER BY o.observed_at DESC, o.rowid DESC LIMIT 1`),
- sources: db.prepare(`SELECT * FROM deal_offer_sources WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY created_at, rowid`),
- votes: db.prepare(`SELECT offer_id, subject, value, weight, ip_hash, updated_at, rowid AS seq FROM deal_votes
- WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY updated_at, rowid`),
+ WHERE o.offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) AND s.removed_at IS NULL
+ ORDER BY o.observed_at DESC, o.seq DESC LIMIT 1`),
+ sources: db.prepare(`SELECT * FROM deal_offer_sources WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY created_at, seq`),
+ votes: db.prepare(`SELECT offer_id, subject, value, weight, ip_hash, updated_at, seq FROM deal_votes
+ WHERE offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY updated_at, seq`),
members: db.prepare('SELECT * FROM deal_offers WHERE merged_into = ? ORDER BY merged_at'),
members: db.prepare('SELECT * FROM deal_offers WHERE merged_into = ? ORDER BY merged_at'),
lastSnapshot: db.prepare('SELECT * FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id DESC LIMIT 1'),
lastSnapshot: db.prepare('SELECT * FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id DESC LIMIT 1'),
};
};
- const get = (id) => (id ? q.byId.get(String(id)) : null);
+ const get = async (id) => (id ? await q.byId.get(String(id)) : null);
/** By id (dof_…) or slug. */
/** By id (dof_…) or slug. */
- function find(idOrSlug) {
+ async function find(idOrSlug) {
const s = String(idOrSlug || '');
const s = String(idOrSlug || '');
- return s.startsWith('dof_') ? get(s) : q.bySlug.get(s) || null;
+ return s.startsWith('dof_') ? await get(s) : await q.bySlug.get(s) || null;
}
}
- function mustFind(idOrSlug) {
- const o = find(idOrSlug);
+ async function mustFind(idOrSlug) {
+ const o = await find(idOrSlug);
if (!o) throw new ApiError(404, 'offer.not_found', 'No such offer');
if (!o) throw new ApiError(404, 'offer.not_found', 'No such offer');
return o;
return o;
}
}
/** The canonical offer a (possibly merged) offer resolves to. */
/** The canonical offer a (possibly merged) offer resolves to. */
- function root(offer) {
+ async function root(offer) {
let cur = offer;
let cur = offer;
- for (let i = 0; cur && cur.merged_into && i < 50; i++) cur = get(cur.merged_into);
+ for (let i = 0; cur && cur.merged_into && i < 50; i++) cur = await get(cur.merged_into);
return cur;
return cur;
}
}
- const groupIds = (rootId) => q.group.all(rootId).map((r) => r.id);
+ const groupIds = async (rootId) => (await q.group.all(rootId)).map((r) => r.id);
const J = (list) => JSON.stringify(list);
const J = (list) => JSON.stringify(list);
/** Each subject's effective vote in a group: their most recent row (0 = removed). */
/** Each subject's effective vote in a group: their most recent row (0 = removed). */
- function effectiveVotes(ids) {
+ async function effectiveVotes(ids) {
const bySubject = new Map();
const bySubject = new Map();
- for (const v of q.votes.all(J(ids))) bySubject.set(v.subject, v);
+ for (const v of await q.votes.all(J(ids))) bySubject.set(v.subject, v);
return bySubject;
return bySubject;
}
}
- function tally(ids) {
+ async function tally(ids) {
let up = 0, down = 0, upW = 0, downW = 0;
let up = 0, down = 0, upW = 0, downW = 0;
- for (const v of effectiveVotes(ids).values()) {
+ for (const v of (await effectiveVotes(ids)).values()) {
if (v.value === 1) { up++; upW += v.weight; } else if (v.value === -1) { down++; downW += v.weight; }
if (v.value === 1) { up++; upW += v.weight; } else if (v.value === -1) { down++; downW += v.weight; }
}
}
const r4 = (x) => Math.round(x * 10000) / 10000;
const r4 = (x) => Math.round(x * 10000) / 10000;
@@ -79,13 +79,13 @@ function createReads({ store }) {
@@ -79,13 +79,13 @@ function createReads({ store }) {
mustFind,
mustFind,
root,
root,
groupIds,
groupIds,
- observations: (ids) => q.observations.all(J(ids)),
- latestObservation: (ids) => q.latest.get(J(ids)) || null,
- sources: (ids) => q.sources.all(J(ids)),
+ observations: async (ids) => await q.observations.all(J(ids)),
+ latestObservation: async (ids) => await q.latest.get(J(ids)) || null,
+ sources: async (ids) => await q.sources.all(J(ids)),
effectiveVotes,
effectiveVotes,
tally,
tally,
- mergedMembers: (id) => q.members.all(id),
- lastSnapshot: (id) => q.lastSnapshot.get(id) || null,
+ mergedMembers: async (id) => await q.members.all(id),
+ lastSnapshot: async (id) => await q.lastSnapshot.get(id) || null,
};
};
}
}
@@ -40,38 +40,38 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, out
@@ -40,38 +40,38 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, out
VALUES (@offer_id, @subject, @value, @weight, @ip_hash, @via, @now, @at)
VALUES (@offer_id, @subject, @value, @weight, @ip_hash, @via, @now, @at)
ON CONFLICT (offer_id, subject) DO UPDATE SET value = excluded.value, weight = excluded.weight,
ON CONFLICT (offer_id, subject) DO UPDATE SET value = excluded.value, weight = excluded.weight,
ip_hash = COALESCE(excluded.ip_hash, deal_votes.ip_hash), via = excluded.via, updated_at = excluded.updated_at`),
ip_hash = COALESCE(excluded.ip_hash, deal_votes.ip_hash), via = excluded.via, updated_at = excluded.updated_at`),
- lastInGroup: db.prepare('SELECT MAX(updated_at) AS t FROM deal_votes WHERE subject = ? AND offer_id IN (SELECT value FROM json_each(?))'),
- sameIp: db.prepare(`SELECT DISTINCT subject FROM deal_votes WHERE ip_hash = ? AND value <> 0 AND offer_id IN (SELECT value FROM json_each(?)) ORDER BY subject`),
+ lastInGroup: db.prepare('SELECT MAX(updated_at) AS t FROM deal_votes WHERE subject = ? AND offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb))'),
+ sameIp: db.prepare(`SELECT DISTINCT subject FROM deal_votes WHERE ip_hash = ? AND value <> 0 AND offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) ORDER BY subject`),
covote: db.prepare(`SELECT b.subject AS other, COUNT(DISTINCT a.offer_id) AS n
covote: db.prepare(`SELECT b.subject AS other, COUNT(DISTINCT a.offer_id) AS n
FROM deal_votes a JOIN deal_votes b
FROM deal_votes a JOIN deal_votes b
ON b.offer_id = a.offer_id AND b.subject <> a.subject AND b.value = a.value AND a.value <> 0
ON b.offer_id = a.offer_id AND b.subject <> a.subject AND b.value = a.value AND a.value <> 0
AND ABS(a.updated_at - b.updated_at) <= @win
AND ABS(a.updated_at - b.updated_at) <= @win
- WHERE a.subject = @s GROUP BY b.subject HAVING n >= @min ORDER BY b.subject`),
+ WHERE a.subject = @s GROUP BY b.subject HAVING COUNT(DISTINCT a.offer_id) >= @min ORDER BY b.subject`),
};
};
- function weightFor(subject, now = store.now()) {
+ async function weightFor(subject, now = store.now()) {
const minted = subjectTime(subject);
const minted = subjectTime(subject);
- const seen = people.firstSeen(subject);
+ const seen = await people.firstSeen(subject);
const evidence = Math.max(minted != null ? now - minted : 0, seen != null ? now - seen : 0);
const evidence = Math.max(minted != null ? now - minted : 0, seen != null ? now - seen : 0);
return evidence >= config.abuse.newAccountDays * DAY ? 1 : config.abuse.newAccountWeight;
return evidence >= config.abuse.newAccountDays * DAY ? 1 : config.abuse.newAccountWeight;
}
}
- function detectRings(root, ids, subject, ipHash) {
+ async function detectRings(root, ids, subject, ipHash) {
const raised = [];
const raised = [];
if (ipHash) {
if (ipHash) {
- const subjects = q.sameIp.all(ipHash, JSON.stringify(ids)).map((r) => r.subject);
+ const subjects = (await q.sameIp.all(ipHash, JSON.stringify(ids))).map((r) => r.subject);
if (subjects.length >= config.abuse.ringIpMin) {
if (subjects.length >= config.abuse.ringIpMin) {
- raised.push(flags.raise({
+ raised.push(await flags.raise({
kind: 'vote_ring', offerId: root.id, key: `ring:ip:${root.id}:${ipHash}`, reason: 'shared_ip',
kind: 'vote_ring', offerId: root.id, key: `ring:ip:${root.id}:${ipHash}`, reason: 'shared_ip',
details: { signal: 'shared_ip', ip_hash: ipHash, voters: subjects },
details: { signal: 'shared_ip', ip_hash: ipHash, voters: subjects },
}));
}));
}
}
}
}
- const partners = q.covote.all({ s: subject, win: config.abuse.ringWindowMs, min: config.abuse.ringCovoteMin });
+ const partners = await q.covote.all({ s: subject, win: config.abuse.ringWindowMs, min: config.abuse.ringCovoteMin });
if (partners.length) {
if (partners.length) {
const voters = [subject, ...partners.map((p) => p.other)].sort();
const voters = [subject, ...partners.map((p) => p.other)].sort();
const key = `ring:covote:${crypto.createHash('sha256').update(voters.join(',')).digest('hex').slice(0, 20)}`;
const key = `ring:covote:${crypto.createHash('sha256').update(voters.join(',')).digest('hex').slice(0, 20)}`;
- raised.push(flags.raise({
+ raised.push(await flags.raise({
kind: 'vote_ring', offerId: root.id, key, reason: 'covote',
kind: 'vote_ring', offerId: root.id, key, reason: 'covote',
details: { signal: 'covote', voters, shared_offers: Object.fromEntries(partners.map((p) => [p.other, p.n])), window_minutes: config.abuse.ringWindowMs / 60000 },
details: { signal: 'covote', voters, shared_offers: Object.fromEntries(partners.map((p) => [p.other, p.n])), window_minutes: config.abuse.ringWindowMs / 60000 },
}));
}));
@@ -80,36 +80,36 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, out
@@ -80,36 +80,36 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, out
}
}
/** value: 1 | -1 (set) or 0 (remove). */
/** value: 1 | -1 (set) or 0 (remove). */
- function apply(viewer, idOrSlug, value, { ip = null, traceparent } = {}) {
+ async function apply(viewer, idOrSlug, value, { ip = null, traceparent } = {}) {
const subject = viewer && viewer.subject;
const subject = viewer && viewer.subject;
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to vote');
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to vote');
if (![1, -1, 0].includes(value)) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
if (![1, -1, 0].includes(value)) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
- const listing = reads.mustFind(idOrSlug);
- const root = reads.root(listing);
+ const listing = await reads.mustFind(idOrSlug);
+ const root = await reads.root(listing);
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
- const ids = reads.groupIds(root.id);
- if (ids.some((id) => { const o = reads.get(id); return o && o.submitted_by === subject; })) {
+ const ids = await reads.groupIds(root.id);
+ if ((await Promise.all(ids.map(async (id) => await reads.get(id)))).some((o) => o && o.submitted_by === subject)) {
throw new ApiError(403, 'vote.own_offer', 'You cannot vote on a deal you posted');
throw new ApiError(403, 'vote.own_offer', 'You cannot vote on a deal you posted');
}
}
const ipHash = limits.ipHash(ip);
const ipHash = limits.ipHash(ip);
- return store.tx(() => {
- const current = reads.effectiveVotes(ids).get(subject);
+ return await store.tx(async () => {
+ const current = (await reads.effectiveVotes(ids)).get(subject);
const previous = current ? current.value : 0;
const previous = current ? current.value : 0;
- if (previous === value) return { changed: false, value, previous, tally: reads.tally(ids), root };
- limits.check('vote', subject, config.abuse.voteSubjectPerHour, 3600 * 1000, 'vote.rate_limited');
- if (ipHash) limits.check('vote_ip', ipHash, config.abuse.voteIpPerHour, 3600 * 1000, 'vote.rate_limited');
- limits.check('vote_offer', `${subject}:${root.id}`, config.abuse.voteOfferChangesPerHour, 3600 * 1000, 'vote.rate_limited');
+ if (previous === value) return { changed: false, value, previous, tally: await reads.tally(ids), root };
+ await limits.check('vote', subject, config.abuse.voteSubjectPerHour, 3600 * 1000, 'vote.rate_limited');
+ if (ipHash) await limits.check('vote_ip', ipHash, config.abuse.voteIpPerHour, 3600 * 1000, 'vote.rate_limited');
+ await limits.check('vote_offer', `${subject}:${root.id}`, config.abuse.voteOfferChangesPerHour, 3600 * 1000, 'vote.rate_limited');
const now = store.now();
const now = store.now();
- const last = q.lastInGroup.get(subject, JSON.stringify(ids)).t;
- const weight = weightFor(subject, now);
+ const last = (await q.lastInGroup.get(subject, JSON.stringify(ids))).t;
+ const weight = await weightFor(subject, now);
// updated_at strictly after the person's previous row in the group: "most recent" is exact.
// updated_at strictly after the person's previous row in the group: "most recent" is exact.
- q.upsert.run({ offer_id: root.id, subject, value, weight, ip_hash: ipHash, via: viewer.kind === 'service' ? 'service' : 'user', now, at: last != null && last >= now ? last + 1 : now });
- limits.hit('vote', subject);
- if (ipHash) limits.hit('vote_ip', ipHash);
- limits.hit('vote_offer', `${subject}:${root.id}`);
- const snap = hotness.snapshot(root.id, 'vote', now);
- const tally = reads.tally(ids);
- outbox.emit({
+ await q.upsert.run({ offer_id: root.id, subject, value, weight, ip_hash: ipHash, via: viewer.kind === 'service' ? 'service' : 'user', now, at: last != null && last >= now ? last + 1 : now });
+ await limits.hit('vote', subject);
+ if (ipHash) await limits.hit('vote_ip', ipHash);
+ await limits.hit('vote_offer', `${subject}:${root.id}`);
+ const snap = await hotness.snapshot(root.id, 'vote', now);
+ const tally = await reads.tally(ids);
+ await outbox.emit({
event_type: 'deals.vote.changed',
event_type: 'deals.vote.changed',
version: 1,
version: 1,
source: 'deals',
source: 'deals',
@@ -122,20 +122,20 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, out
@@ -122,20 +122,20 @@ function createVotes({ config, store, reads, hotness, limits, flags, people, out
hotness: { formula: snap.formula, hot: snap.hot, score: snap.score },
hotness: { formula: snap.formula, hot: snap.hot, score: snap.score },
},
},
}, { traceparent });
}, { traceparent });
- const rings = value !== 0 ? detectRings(root, ids, subject, ipHash) : [];
+ const rings = value !== 0 ? await detectRings(root, ids, subject, ipHash) : [];
return { changed: true, value, previous, weight, tally, hotness: snap, rings, root };
return { changed: true, value, previous, weight, tally, hotness: snap, rings, root };
});
});
}
}
- function mine(subject, root) {
+ async function mine(subject, root) {
if (!subject || !root) return 0;
if (!subject || !root) return 0;
- const v = reads.effectiveVotes(reads.groupIds(root.id)).get(subject);
+ const v = (await reads.effectiveVotes(await reads.groupIds(root.id))).get(subject);
return v ? v.value : 0;
return v ? v.value : 0;
}
}
return {
return {
- set: (viewer, id, value, opts) => apply(viewer, id, Number(value), opts),
- remove: (viewer, id, opts) => apply(viewer, id, 0, opts),
+ set: async (viewer, id, value, opts) => await apply(viewer, id, Number(value), opts),
+ remove: async (viewer, id, opts) => await apply(viewer, id, 0, opts),
mine,
mine,
weightFor,
weightFor,
};
};
@@ -35,22 +35,22 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
@@ -35,22 +35,22 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
VALUES (@id, @subject, @kind, @query, @product_id, @max_price, @max_price_num, @currency, @notify, @label, @now)`),
VALUES (@id, @subject, @kind, @query, @product_id, @max_price, @max_price_num, @currency, @notify, @label, @now)`),
remove: db.prepare('UPDATE deal_watches SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL'),
remove: db.prepare('UPDATE deal_watches SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL'),
active: db.prepare("SELECT * FROM deal_watches WHERE notify = 1 AND deleted_at IS NULL AND kind <> 'search'"),
active: db.prepare("SELECT * FROM deal_watches WHERE notify = 1 AND deleted_at IS NULL AND kind <> 'search'"),
- claim: db.prepare(`INSERT OR IGNORE INTO watch_notifications (watch_id, observation_id, offer_id, price_num, currency, created_at) VALUES (?, ?, ?, ?, ?, ?)`),
+ claim: db.prepare(`INSERT INTO watch_notifications (watch_id, observation_id, offer_id, price_num, currency, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),
setEvent: db.prepare('UPDATE watch_notifications SET event_id = ? WHERE watch_id = ? AND observation_id = ?'),
setEvent: db.prepare('UPDATE watch_notifications SET event_id = ? WHERE watch_id = ? AND observation_id = ?'),
priorForGroup: db.prepare(`SELECT price_num, currency FROM watch_notifications
priorForGroup: db.prepare(`SELECT price_num, currency FROM watch_notifications
- WHERE watch_id = ? AND offer_id IN (SELECT value FROM json_each(?)) AND event_id IS NOT NULL`),
+ WHERE watch_id = ? AND offer_id IN (SELECT value FROM jsonb_array_elements_text(?::jsonb)) AND event_id IS NOT NULL`),
notifications: db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE watch_id = ? AND event_id IS NOT NULL'),
notifications: db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE watch_id = ? AND event_id IS NOT NULL'),
};
};
- function create(subject, input) {
+ async function create(subject, input) {
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to save watches');
if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to save watches');
const kind = String(input.kind || '');
const kind = String(input.kind || '');
if (!KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${KINDS.join(', ')}`);
if (!KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${KINDS.join(', ')}`);
- if (q.countFor.get(subject).n >= config.abuse.watchesPerSubject) throw new ApiError(429, 'watch.limit', `At most ${config.abuse.watchesPerSubject} watches and saved searches`);
+ if ((await q.countFor.get(subject)).n >= config.abuse.watchesPerSubject) throw new ApiError(429, 'watch.limit', `At most ${config.abuse.watchesPerSubject} watches and saved searches`);
const query = text(input.query, { field: 'query', max: 200 });
const query = text(input.query, { field: 'query', max: 200 });
let product = null;
let product = null;
if (input.product) {
if (input.product) {
- product = catalog.productBySlug(input.product) || catalog.product(input.product);
+ product = await catalog.productBySlug(input.product) || await catalog.product(input.product);
if (!product) throw new ApiError(404, 'product.not_found', 'No such product');
if (!product) throw new ApiError(404, 'product.not_found', 'No such product');
}
}
const row = { id: newId('dwt'), subject, kind, query: null, product_id: null, max_price: null, max_price_num: null, currency: null, notify: kind === 'search' ? 0 : 1, label: text(input.label, { max: 120 }), now: store.now() };
const row = { id: newId('dwt'), subject, kind, query: null, product_id: null, max_price: null, max_price_num: null, currency: null, notify: kind === 'search' ? 0 : 1, label: text(input.label, { max: 120 }), now: store.now() };
@@ -67,14 +67,14 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
@@ -67,14 +67,14 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
if (!product && !(query && tokens(query).length)) throw new ApiError(422, 'request.invalid', 'a price watch needs a product or keywords');
if (!product && !(query && tokens(query).length)) throw new ApiError(422, 'request.invalid', 'a price watch needs a product or keywords');
Object.assign(row, { product_id: product ? product.id : null, query: product ? null : query, max_price: amount.text, max_price_num: amount.num, currency });
Object.assign(row, { product_id: product ? product.id : null, query: product ? null : query, max_price: amount.text, max_price_num: amount.num, currency });
}
}
- q.insert.run(row);
- return q.get.get(row.id);
+ await q.insert.run(row);
+ return await q.get.get(row.id);
}
}
- function remove(subject, id, { staff = false } = {}) {
- const w = q.get.get(String(id || ''));
+ async function remove(subject, id, { staff = false } = {}) {
+ const w = await q.get.get(String(id || ''));
if (!w || w.deleted_at || (w.subject !== subject && !staff)) throw new ApiError(404, 'watch.not_found', 'No such watch');
if (!w || w.deleted_at || (w.subject !== subject && !staff)) throw new ApiError(404, 'watch.not_found', 'No such watch');
- q.remove.run(store.now(), w.id);
+ await q.remove.run(store.now(), w.id);
return { id: w.id, deleted: true };
return { id: w.id, deleted: true };
}
}
@@ -95,23 +95,23 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
@@ -95,23 +95,23 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
}
}
/** Inside the observation's transaction. Returns the envelopes emitted. */
/** Inside the observation's transaction. Returns the envelopes emitted. */
- function onObservation(obs) {
- const offer = reads.get(obs.offer_id);
- const v = publication.offerView(offer);
+ async function onObservation(obs) {
+ const offer = await reads.get(obs.offer_id);
+ const v = await publication.offerView(offer);
if (v.root.status !== 'active') return [];
if (v.root.status !== 'active') return [];
if (store.now() - obs.observed_at > config.freshnessMs) return [];
if (store.now() - obs.observed_at > config.freshnessMs) return [];
const words = offerText(v);
const words = offerText(v);
const out = [];
const out = [];
- for (const w of q.active.all()) {
+ for (const w of await q.active.all()) {
if (obs.observed_by && obs.observed_by === w.subject) continue;
if (obs.observed_by && obs.observed_by === w.subject) continue;
if (v.root.submitted_by && v.root.submitted_by === w.subject && obs.origin === 'community') continue;
if (v.root.submitted_by && v.root.submitted_by === w.subject && obs.origin === 'community') continue;
if (!matches(w, v, obs, words)) continue;
if (!matches(w, v, obs, words)) continue;
- const prior = q.priorForGroup.all(w.id, JSON.stringify(v.ids));
+ const prior = await q.priorForGroup.all(w.id, JSON.stringify(v.ids));
if (w.kind !== 'price_below' && prior.length) continue;
if (w.kind !== 'price_below' && prior.length) continue;
if (w.kind === 'price_below' && prior.some((p) => p.currency === obs.currency && p.price_num != null && p.price_num <= obs.price_num)) continue;
if (w.kind === 'price_below' && prior.some((p) => p.currency === obs.currency && p.price_num != null && p.price_num <= obs.price_num)) continue;
// The uniqueness: at most one row, hence one event, per (watch, observation).
// The uniqueness: at most one row, hence one event, per (watch, observation).
- if (q.claim.run(w.id, obs.id, v.root.id, obs.price_num, obs.currency, store.now()).changes === 0) continue;
- const env = outbox.emit({
+ if ((await q.claim.run(w.id, obs.id, v.root.id, obs.price_num, obs.currency, store.now())).changes === 0) continue;
+ const env = await outbox.emit({
event_type: 'deals.watch.matched',
event_type: 'deals.watch.matched',
version: 1,
version: 1,
source: 'deals',
source: 'deals',
@@ -125,23 +125,23 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
@@ -125,23 +125,23 @@ function createWatches({ config, store, reads, catalog, publication, outbox }) {
observation: { id: obs.id, observed_at: iso(obs.observed_at), price: obs.price, currency: obs.currency, availability: obs.availability },
observation: { id: obs.id, observed_at: iso(obs.observed_at), price: obs.price, currency: obs.currency, availability: obs.availability },
},
},
});
});
- q.setEvent.run(env.event_id, w.id, obs.id);
+ await q.setEvent.run(env.event_id, w.id, obs.id);
out.push(env);
out.push(env);
}
}
return out;
return out;
}
}
- function dto(w) {
- const product = w.product_id ? catalog.product(w.product_id) : null;
+ async function dto(w) {
+ const product = w.product_id ? await catalog.product(w.product_id) : null;
return {
return {
id: w.id, kind: w.kind, query: w.query, label: w.label, notify: Boolean(w.notify),
id: w.id, kind: w.kind, query: w.query, label: w.label, notify: Boolean(w.notify),
product: product ? { id: product.id, slug: product.slug, name: product.name, url: publication.abs(publication.productPath(product)) } : null,
product: product ? { id: product.id, slug: product.slug, name: product.name, url: publication.abs(publication.productPath(product)) } : null,
max_price: w.max_price, currency: w.currency, created_at: iso(w.created_at),
max_price: w.max_price, currency: w.currency, created_at: iso(w.created_at),
- notifications: q.notifications.get(w.id).n,
+ notifications: (await q.notifications.get(w.id)).n,
};
};
}
}
- return { create, remove, onObservation, list: (subject) => q.listFor.all(subject), get: (id) => q.get.get(id), dto, KINDS };
+ return { create, remove, onObservation, list: async (subject) => await q.listFor.all(subject), get: async (id) => await q.get.get(id), dto, KINDS };
}
}
module.exports = { createWatches, KINDS };
module.exports = { createWatches, KINDS };
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
*/
*/
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
@@ -39,7 +39,9 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -39,7 +39,9 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
}
}
const events = createEventsClient(createClient(clientOpts), { source: 'deals' });
const events = createEventsClient(createClient(clientOpts), { source: 'deals' });
let lastError = null;
let lastError = null;
- const outbox = createOutbox(db, {
+ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient
+ // transaction); several processes relay one table safely (leases).
+ const outbox = createPgOutbox(db, {
events,
events,
intervalMs: config.events.intervalMs,
intervalMs: config.events.intervalMs,
now,
now,
@@ -49,20 +51,19 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -49,20 +51,19 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
lastError = msg;
lastError = msg;
},
},
});
});
- outbox.ensureSchema();
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
- function emit(envelope, { traceparent } = {}) {
- return outbox.enqueue(envelope, { traceparent });
+ async function emit(envelope, { traceparent } = {}) {
+ return await outbox.enqueue(db, envelope, { traceparent });
}
}
/**
/**
* deals.moderation.action, inside the caller's transaction. actorSubject: the moderator (null for
* deals.moderation.action, inside the caller's transaction. actorSubject: the moderator (null for
* a service acting without a person); target: { type, id, owner_subject? }. Never the content.
* a service acting without a person); target: { type, id, owner_subject? }. Never the content.
*/
*/
- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
+ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
- return emit({
+ return await emit({
event_type: 'deals.moderation.action',
event_type: 'deals.moderation.action',
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'deals' },
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'deals' },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
@@ -78,8 +79,8 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -78,8 +79,8 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
enabled,
enabled,
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
stop: () => outbox.stop(),
stop: () => outbox.stop(),
- kick() { if (enabled) outbox.kick(); },
- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ async kick() { if (enabled) await outbox.kick(); },
+ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
};
};
}
}
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
* deliveries at /internal/events (Sources wake-ups: Events pushes at its own pace, and a 429 would only
* deliveries at /internal/events (Sources wake-ups: Events pushes at its own pace, and a 429 would only
* make it retry and fall behind), and the pages and feeds people read.
* make it retry and fall behind), and the pages and feeds people read.
*/
*/
-const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
const FIRST_PARTY = /^svc:/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
@@ -85,7 +85,7 @@ const BUDGETS = {
@@ -85,7 +85,7 @@ const BUDGETS = {
* limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on every GET/HEAD,
* limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on every GET/HEAD,
* a first-party service reading for itself not counted) and limits.budget(name) (one of BUDGETS).
* a first-party service reading for itself not counted) and limits.budget(name) (one of BUDGETS).
*/
*/
-function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {
const refused = registry
const refused = registry
? registry.counter({ name: 'deals_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'deals_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -93,6 +93,10 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -93,6 +93,10 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
actor,
actor,
now,
now,
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is a subject id, a principal or an address, never a token.
// The actor is a subject id, a principal or an address, never a token.
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -61,31 +61,31 @@ function createApi(ctx) {
@@ -61,31 +61,31 @@ function createApi(ctx) {
router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
const opts = (req) => ({ ip: req.viewer.kind === 'service' ? null : req.ip, traceparent: req.ov && req.ov.traceparent });
const opts = (req) => ({ ip: req.viewer.kind === 'service' ? null : req.ip, traceparent: req.ov && req.ov.traceparent });
- const offerOut = (offer) => ({ offer: publication.offerDto(publication.offerView(offer)) });
+ const offerOut = async (offer) => ({ offer: publication.offerDto(await publication.offerView(offer)) });
const needSubject = (req) => { if (!req.viewer.subject) throw new ApiError(req.viewer.kind === 'service' ? 400 : 401, req.viewer.kind === 'service' ? 'subject.required' : 'auth.required', req.viewer.kind === 'service' ? 'X-OV-Subject must name the person this action is for' : 'Sign in first'); };
const needSubject = (req) => { if (!req.viewer.subject) throw new ApiError(req.viewer.kind === 'service' ? 400 : 401, req.viewer.kind === 'service' ? 'subject.required' : 'auth.required', req.viewer.kind === 'service' ? 'X-OV-Subject must name the person this action is for' : 'Sign in first'); };
// ── offers ──────────────────────────────────────────────
// ── offers ──────────────────────────────────────────────
- router.get('/offers', run((req) => {
+ router.get('/offers', run(async (req) => {
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const per = 25;
const per = 25;
- const rows = req.query.sort === 'new' ? listings.newest(per, (page - 1) * per) : listings.hot(per, (page - 1) * per);
- return { sort: req.query.sort === 'new' ? 'new' : 'hot', page, total: listings.countActive(), offers: rows.map((o) => publication.offerDto(publication.offerView(o))) };
+ const rows = req.query.sort === 'new' ? await listings.newest(per, (page - 1) * per) : await listings.hot(per, (page - 1) * per);
+ return { sort: req.query.sort === 'new' ? 'new' : 'hot', page, total: await listings.countActive(), offers: await Promise.all(rows.map(async (o) => publication.offerDto(await publication.offerView(o)))) };
}));
}));
// A removed deal answers like its page (410, reason only); moderators still read the whole record.
// A removed deal answers like its page (410, reason only); moderators still read the whole record.
- router.get('/offers/:id', run((req) => {
- const offer = reads.mustFind(req.params.id);
- const root = reads.root(offer);
+ router.get('/offers/:id', run(async (req) => {
+ const offer = await reads.mustFind(req.params.id);
+ const root = await reads.root(offer);
if (root.status === 'disabled' && !access.isModerator(req.viewer)) {
if (root.status === 'disabled' && !access.isModerator(req.viewer)) {
throw new ApiError(410, 'offer.disabled', 'This deal was removed by moderators', { id: root.id, slug: root.slug, status: 'disabled', reason: root.disabled_reason });
throw new ApiError(410, 'offer.disabled', 'This deal was removed by moderators', { id: root.id, slug: root.slug, status: 'disabled', reason: root.disabled_reason });
}
}
- return offerOut(offer);
+ return await offerOut(offer);
}));
}));
- router.get('/offers/:id/hotness', run((req) => {
- const root = reads.root(reads.mustFind(req.params.id));
- const snap = reads.lastSnapshot(root.id);
+ router.get('/offers/:id/hotness', run(async (req) => {
+ const root = await reads.root(await reads.mustFind(req.params.id));
+ const snap = await reads.lastSnapshot(root.id);
return {
return {
offer_id: root.id,
offer_id: root.id,
formula: 'hot@1: score = upWeight − downWeight; hot = round6(score / (ageHours + 2)^1.5), ageHours = (t − firstSeenAt) / 3600000',
formula: 'hot@1: score = upWeight − downWeight; hot = round6(score / (ageHours + 2)^1.5), ageHours = (t − firstSeenAt) / 3600000',
@@ -94,110 +94,110 @@ function createApi(ctx) {
@@ -94,110 +94,110 @@ function createApi(ctx) {
};
};
}));
}));
- router.post('/offers', guard('deals.offer.submit'), B('deals.offer.submit'), jsonBody, run((req) => {
+ router.post('/offers', guard('deals.offer.submit'), B('deals.offer.submit'), jsonBody, run(async (req) => {
needSubject(req);
needSubject(req);
- const out = offers.submit(req.viewer, req.body || {}, opts(req));
- return offerOut(out.offer);
+ const out = await offers.submit(req.viewer, req.body || {}, opts(req));
+ return await offerOut(out.offer);
}, 201));
}, 201));
- router.patch('/offers/:id', guard('deals.offer.update'), B('deals.offer.update'), jsonBody, run((req) => offerOut(offers.update(req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
+ router.patch('/offers/:id', guard('deals.offer.update'), B('deals.offer.update'), jsonBody, run(async (req) => await offerOut((await offers.update(req.viewer, req.params.id, req.body || {}, opts(req))).offer)));
- router.post('/offers/:id/observations', guard('deals.offer.update'), B('deals.offer.observe'), jsonBody, run((req) => {
+ router.post('/offers/:id/observations', guard('deals.offer.update'), B('deals.offer.observe'), jsonBody, run(async (req) => {
needSubject(req);
needSubject(req);
- const out = offers.observe(req.viewer, req.params.id, req.body || {}, opts(req));
- return { ...offerOut(out.offer), observation: publication.observationDto({ ...out.observation, source_kind: 'community' }) };
+ const out = await offers.observe(req.viewer, req.params.id, req.body || {}, opts(req));
+ return { ...await offerOut(out.offer), observation: publication.observationDto({ ...out.observation, source_kind: 'community' }) };
}, 201));
}, 201));
- router.post('/offers/:id/expire', guard('deals.offer.expire'), B('deals.offer.update'), jsonBody, run((req) => offerOut(offers.expire(req.viewer, req.params.id, req.body || {}, opts(req)))));
+ router.post('/offers/:id/expire', guard('deals.offer.expire'), B('deals.offer.update'), jsonBody, run(async (req) => await offerOut(await offers.expire(req.viewer, req.params.id, req.body || {}, opts(req)))));
- router.put('/offers/:id/vote', guard('deals.vote.set'), B('deals.vote'), jsonBody, run((req) => {
+ router.put('/offers/:id/vote', guard('deals.vote.set'), B('deals.vote'), jsonBody, run(async (req) => {
needSubject(req);
needSubject(req);
const value = Number((req.body || {}).value);
const value = Number((req.body || {}).value);
if (value !== 1 && value !== -1) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
if (value !== 1 && value !== -1) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
- const r = votes.set(req.viewer, req.params.id, value, opts(req));
+ const r = await votes.set(req.viewer, req.params.id, value, opts(req));
return { offer_id: r.root.id, value: r.value, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
return { offer_id: r.root.id, value: r.value, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
}));
}));
- router.delete('/offers/:id/vote', guard('deals.vote.remove'), B('deals.vote'), run((req) => {
+ router.delete('/offers/:id/vote', guard('deals.vote.remove'), B('deals.vote'), run(async (req) => {
needSubject(req);
needSubject(req);
- const r = votes.remove(req.viewer, req.params.id, opts(req));
+ const r = await votes.remove(req.viewer, req.params.id, opts(req));
return { offer_id: r.root.id, value: 0, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
return { offer_id: r.root.id, value: 0, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
}));
}));
- router.post('/offers/:id/flags', guard('deals.flag.create'), B('deals.flag.create'), jsonBody, run((req) => {
+ router.post('/offers/:id/flags', guard('deals.flag.create'), B('deals.flag.create'), jsonBody, run(async (req) => {
needSubject(req);
needSubject(req);
- const r = flags.report(req.viewer, req.params.id, req.body || {}, opts(req));
+ const r = await flags.report(req.viewer, req.params.id, req.body || {}, opts(req));
return { flag: { id: r.flag.id, kind: r.flag.kind, status: r.flag.status }, created: r.created };
return { flag: { id: r.flag.id, kind: r.flag.kind, status: r.flag.status }, created: r.created };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
- router.post('/offers/:id/merge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run((req) => {
+ router.post('/offers/:id/merge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run(async (req) => {
const b = req.body || {};
const b = req.body || {};
- const r = offers.merge(req.viewer, req.params.id, b.into, { reason: b.reason }, opts(req));
- return { ...offerOut(r.offer), merged: { id: r.merged.id, slug: r.merged.slug }, before: r.before, after: r.after };
+ const r = await offers.merge(req.viewer, req.params.id, b.into, { reason: b.reason }, opts(req));
+ return { ...await offerOut(r.offer), merged: { id: r.merged.id, slug: r.merged.slug }, before: r.before, after: r.after };
}));
}));
- router.post('/offers/:id/unmerge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run((req) => {
- const r = offers.unmerge(req.viewer, req.params.id, req.body || {}, opts(req));
- return { ...offerOut(r.offer), from: { id: r.from.id, slug: r.from.slug }, before: r.before, after: r.after };
+ router.post('/offers/:id/unmerge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run(async (req) => {
+ const r = await offers.unmerge(req.viewer, req.params.id, req.body || {}, opts(req));
+ return { ...await offerOut(r.offer), from: { id: r.from.id, slug: r.from.slug }, before: r.before, after: r.after };
}));
}));
for (const action of ['disable', 'enable', 'review']) {
for (const action of ['disable', 'enable', 'review']) {
- router.post(`/offers/:id/${action}`, guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run((req) => offerOut(offers[action](req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
+ router.post(`/offers/:id/${action}`, guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run(async (req) => await offerOut((await offers[action](req.viewer, req.params.id, req.body || {}, opts(req))).offer)));
}
}
- router.get('/flags', guard('deals.offer.moderate'), run((req) => {
+ router.get('/flags', guard('deals.offer.moderate'), run(async (req) => {
if (!access.isModerator(req.viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
if (!access.isModerator(req.viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
const status = ['open', 'resolved', 'dismissed'].includes(req.query.status) ? req.query.status : 'open';
const status = ['open', 'resolved', 'dismissed'].includes(req.query.status) ? req.query.status : 'open';
- return { flags: flags.list({ status }).map(flags.dto) };
+ return { flags: await Promise.all((await flags.list({ status })).map(flags.dto)) };
}));
}));
- router.post('/flags/:id/resolve', guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run((req) => {
- const r = flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));
- return { flag: flags.dto(r.flag), changed: r.changed };
+ router.post('/flags/:id/resolve', guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run(async (req) => {
+ const r = await flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));
+ return { flag: await flags.dto(r.flag), changed: r.changed };
}));
}));
// ── products and stores ─────────────────────────────────
// ── products and stores ─────────────────────────────────
- router.get('/products/:slug', run((req) => {
- const p = catalog.productBySlug(req.params.slug) || catalog.product(req.params.slug);
+ router.get('/products/:slug', run(async (req) => {
+ const p = await catalog.productBySlug(req.params.slug) || await catalog.product(req.params.slug);
if (!p) throw new ApiError(404, 'product.not_found', 'No such product');
if (!p) throw new ApiError(404, 'product.not_found', 'No such product');
- const pv = publication.productView(p);
+ const pv = await publication.productView(p);
return {
return {
product: { id: p.id, slug: p.slug, name: p.name, brand: p.brand, category: p.category, url: publication.abs(publication.productPath(p)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
product: { id: p.id, slug: p.slug, name: p.name, brand: p.brand, category: p.category, url: publication.abs(publication.productPath(p)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
offers: pv.offers.map((v) => publication.offerDto(v)),
offers: pv.offers.map((v) => publication.offerDto(v)),
};
};
}));
}));
- router.post('/products/resolve', guard('deals.product.resolve'), B('deals.product.resolve'), jsonBody, run((req) => {
+ router.post('/products/resolve', guard('deals.product.resolve'), B('deals.product.resolve'), jsonBody, run(async (req) => {
const b = req.body || {};
const b = req.body || {};
const actor = req.viewer.subject || req.viewer.service || null;
const actor = req.viewer.subject || req.viewer.service || null;
if (!actor) throw new ApiError(401, 'auth.required', 'Sign in first');
if (!actor) throw new ApiError(401, 'auth.required', 'Sign in first');
const create = b.create !== false;
const create = b.create !== false;
- const r = ctx.store.tx(() => {
- const out = catalog.resolve(b, { source: req.viewer.kind === 'service' ? 'import' : 'community', actor, create });
- if (out.created) ctx.indexing.indexProduct(out.product);
+ const r = await ctx.store.tx(async () => {
+ const out = await catalog.resolve(b, { source: req.viewer.kind === 'service' ? 'import' : 'community', actor, create });
+ if (out.created) await ctx.indexing.indexProduct(out.product);
return out;
return out;
});
});
if (!r.product) throw new ApiError(404, 'product.not_found', 'No product has these aliases');
if (!r.product) throw new ApiError(404, 'product.not_found', 'No product has these aliases');
return { product: { id: r.product.id, slug: r.product.slug, name: r.product.name, url: publication.abs(publication.productPath(r.product)) }, created: r.created, conflicts: r.conflicts };
return { product: { id: r.product.id, slug: r.product.slug, name: r.product.name, url: publication.abs(publication.productPath(r.product)) }, created: r.created, conflicts: r.conflicts };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
- router.get('/stores/:domain', run((req) => {
- const st = catalog.storeByDomain(req.params.domain);
+ router.get('/stores/:domain', run(async (req) => {
+ const st = await catalog.storeByDomain(req.params.domain);
if (!st) throw new ApiError(404, 'store.not_found', 'No such store');
if (!st) throw new ApiError(404, 'store.not_found', 'No such store');
- return { store: { id: st.id, domain: st.domain, name: st.name, url: publication.abs(publication.storePath(st)) }, offers: listings.byStore(st.id, 50, 0).map((o) => publication.offerDto(publication.offerView(o))) };
+ return { store: { id: st.id, domain: st.domain, name: st.name, url: publication.abs(publication.storePath(st)) }, offers: await Promise.all((await listings.byStore(st.id, 50, 0)).map(async (o) => publication.offerDto(await publication.offerView(o)))) };
}));
}));
// ── watches ─────────────────────────────────────────────
// ── watches ─────────────────────────────────────────────
- router.get('/watches', guard('deals.watch.read'), run((req) => { needSubject(req); return { watches: watches.list(req.viewer.subject).map(watches.dto) }; }));
+ router.get('/watches', guard('deals.watch.read'), run(async (req) => { needSubject(req); return { watches: await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto)) }; }));
- router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run((req) => {
+ router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run(async (req) => {
needSubject(req);
needSubject(req);
- return { watch: watches.dto(watches.create(req.viewer.subject, req.body || {})) };
+ return { watch: await watches.dto(await watches.create(req.viewer.subject, req.body || {})) };
}, 201));
}, 201));
- router.delete('/watches/:id', guard('deals.watch.delete'), B('deals.watch'), run((req) => { needSubject(req); return watches.remove(req.viewer.subject, req.params.id); }));
+ router.delete('/watches/:id', guard('deals.watch.delete'), B('deals.watch'), run(async (req) => { needSubject(req); return await watches.remove(req.viewer.subject, req.params.id); }));
router.use((req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
router.use((req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -23,17 +23,17 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -23,17 +23,17 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
- function offerEntries() {
- return listings.indexable().map((o) => {
- const v = publication.offerView(o);
+ async function offerEntries() {
+ return Promise.all((await listings.indexable()).map(async (o) => {
+ const v = await publication.offerView(o);
return { loc: abs(publication.offerPath(o)), lastmod: v.latest ? Math.max(v.latest.observed_at, o.updated_at) : o.updated_at, decision: v.decision };
return { loc: abs(publication.offerPath(o)), lastmod: v.latest ? Math.max(v.latest.observed_at, o.updated_at) : o.updated_at, decision: v.decision };
- });
+ }));
}
}
- function productEntries() {
- return listings.products().map((p) => {
- const pv = publication.productView(p);
+ async function productEntries() {
+ return Promise.all((await listings.products()).map(async (p) => {
+ const pv = await publication.productView(p);
return { loc: abs(publication.productPath(p)), lastmod: pv.freshestObservedAt || undefined, decision: pv.decision };
return { loc: abs(publication.productPath(p)), lastmod: pv.freshestObservedAt || undefined, decision: pv.decision };
- });
+ }));
}
}
router.get('/robots.txt', (_req, res) => {
router.get('/robots.txt', (_req, res) => {
@@ -66,15 +66,15 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
@@ -66,15 +66,15 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
}));
}));
});
});
- router.get('/sitemap.xml', (_req, res) => {
- const o = offerEntries().filter((e) => e.decision.indexable).map((e) => e.lastmod);
- const p = productEntries().filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
+ router.get('/sitemap.xml', async (_req, res) => {
+ const o = (await offerEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod);
+ const p = (await productEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});
const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});
xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));
xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));
});
});
- router.get('/sitemaps/offers.xml', (_req, res) => xml(res, seo.sitemap(offerEntries()).files[0]));
- router.get('/sitemaps/products.xml', (_req, res) => xml(res, seo.sitemap(productEntries()).files[0]));
+ router.get('/sitemaps/offers.xml', async (_req, res) => xml(res, seo.sitemap(await offerEntries()).files[0]));
+ router.get('/sitemaps/products.xml', async (_req, res) => xml(res, seo.sitemap(await productEntries()).files[0]));
return router;
return router;
}
}
@@ -11,17 +11,16 @@
@@ -11,17 +11,16 @@
*/
*/
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
-const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');
+const { verifyDeliveryV2, createPgInbox } = require('openvibe-sdk/events');
const CONSUMER = 'deals-sources';
const CONSUMER = 'deals-sources';
const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);
const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);
function createInternalRoutes({ config, store, importer }) {
function createInternalRoutes({ config, store, importer }) {
const router = express.Router();
const router = express.Router();
- const inbox = createInbox(store.db, { now: store.now });
- inbox.ensureSchema();
+ const inbox = createPgInbox(store.db, { now: store.now }); // idempotency_receipts: migrations/0001_initial.sql
- router.post('/internal/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ router.post('/internal/events', express.raw({ type: () => true, limit: '256kb' }), async (req, res, next) => {
const secrets = config.events.webhookSecrets;
const secrets = config.events.webhookSecrets;
if (!secrets.length) return http.sendProblem(res, 503, 'deals.webhook_disabled', { detail: 'DEALS_EVENTS_SECRET is not set', ctx: req.ov });
if (!secrets.length) return http.sendProblem(res, 503, 'deals.webhook_disabled', { detail: 'DEALS_EVENTS_SECRET is not set', ctx: req.ov });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
@@ -33,12 +32,15 @@ function createInternalRoutes({ config, store, importer }) {
@@ -33,12 +32,15 @@ function createInternalRoutes({ config, store, importer }) {
if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
return http.sendProblem(res, 400, 'deals.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });
return http.sendProblem(res, 400, 'deals.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });
}
}
- const r = inbox.once(CONSUMER, event.event_id, () => {
- if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';
- const category = event.payload && event.payload.category;
- if (category !== 'deals') return 'ignored';
- return 'import_scheduled';
- });
+ let r;
+ try {
+ r = await inbox.once(CONSUMER, event.event_id, async () => {
+ if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';
+ const category = event.payload && event.payload.category;
+ if (category !== 'deals') return 'ignored';
+ return 'import_scheduled';
+ });
+ } catch (err) { return next(err); }
if (!r.duplicate && r.result === 'import_scheduled') importer.kick();
if (!r.duplicate && r.result === 'import_scheduled') importer.kick();
res.status(200).json({ event_id: event.event_id, duplicate: Boolean(r.duplicate), outcome: r.duplicate ? null : r.result });
res.status(200).json({ event_id: event.event_id, duplicate: Boolean(r.duplicate), outcome: r.duplicate ? null : r.result });
});
});
@@ -105,14 +105,14 @@ function createPages(ctx) {
@@ -105,14 +105,14 @@ function createPages(ctx) {
// ── lists ───────────────────────────────────────────────
// ── lists ───────────────────────────────────────────────
- function listPage(req, res, kind) {
+ async function listPage(req, res, kind) {
const page = pageNumber(req);
const page = pageNumber(req);
const path = kind === 'hot' ? '/' : '/new';
const path = kind === 'hot' ? '/' : '/new';
- const total = listings.countActive();
+ const total = await listings.countActive();
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
if (pager.outOfRange && total) return notFound(req, res);
if (pager.outOfRange && total) return notFound(req, res);
- const rows = kind === 'hot' ? listings.hot(PER_PAGE, pager.offset) : listings.newest(PER_PAGE, pager.offset);
- const list = rows.map((o) => publication.offerView(o));
+ const rows = kind === 'hot' ? await listings.hot(PER_PAGE, pager.offset) : await listings.newest(PER_PAGE, pager.offset);
+ const list = await Promise.all(rows.map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
send(req, res, 200, {
send(req, res, 200, {
title: kind === 'hot' ? 'Hot deals' : 'New deals',
title: kind === 'hot' ? 'Hot deals' : 'New deals',
@@ -132,14 +132,14 @@ function createPages(ctx) {
@@ -132,14 +132,14 @@ function createPages(ctx) {
}, { cacheable: true });
}, { cacheable: true });
}
}
- router.get('/', wrap(async (req, res) => listPage(req, res, 'hot')));
+ router.get('/', wrap(async (req, res) => await listPage(req, res, 'hot')));
// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, {
router.get('/updates', (req, res) => send(req, res, 200, {
title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',
title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
decision: pageDecision('/updates'), canonical: `${config.baseUrl}/updates`,
body: frame.updatesBody({ service: 'deals', siteName: 'OpenVibe.Deals' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'deals', siteName: 'OpenVibe.Deals' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}, { cacheable: true }));
}, { cacheable: true }));
- router.get('/new', wrap(async (req, res) => listPage(req, res, 'new')));
+ router.get('/new', wrap(async (req, res) => await listPage(req, res, 'new')));
// ── offers ──────────────────────────────────────────────
// ── offers ──────────────────────────────────────────────
@@ -157,7 +157,7 @@ function createPages(ctx) {
@@ -157,7 +157,7 @@ function createPages(ctx) {
}
}
async function renderOffer(req, res, offer, { json = false } = {}) {
async function renderOffer(req, res, offer, { json = false } = {}) {
- const v = publication.offerView(offer);
+ const v = await publication.offerView(offer);
const r = v.root;
const r = v.root;
const dto = publication.offerDto(v);
const dto = publication.offerDto(v);
if (r.status === 'disabled') {
if (r.status === 'disabled') {
@@ -168,7 +168,7 @@ function createPages(ctx) {
@@ -168,7 +168,7 @@ function createPages(ctx) {
const c = r.status === 'disabled' ? { state: 'off' } : await readComments(r, r.title, req);
const c = r.status === 'disabled' ? { state: 'off' } : await readComments(r, r.title, req);
const mergedComments = [];
const mergedComments = [];
for (const m of v.members.slice(0, 3)) {
for (const m of v.members.slice(0, 3)) {
- const tid = community.enabled ? community.knownThread(m) : null;
+ const tid = community.enabled ? await community.knownThread(m) : null;
if (!tid) continue;
if (!tid) continue;
try { const data = await community.readThread(tid, { ctx: req.ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }
try { const data = await community.readThread(tid, { ctx: req.ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }
}
}
@@ -182,21 +182,21 @@ function createPages(ctx) {
@@ -182,21 +182,21 @@ function createPages(ctx) {
type: 'website',
type: 'website',
jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
body: views.offerPage({
body: views.offerPage({
- v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? votes.mine(req.viewer.subject, r) : 0,
+ v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? await votes.mine(req.viewer.subject, r) : 0,
comments: c, mergedComments, canEdit: access.canEdit(req.viewer, r), isMod,
comments: c, mergedComments, canEdit: access.canEdit(req.viewer, r), isMod,
- log: isMod ? offers.moderationLog(r) : null, flags: isMod ? flags.forOffer(v.ids).map(flags.dto) : null, urls,
+ log: isMod ? await offers.moderationLog(r) : null, flags: isMod ? await Promise.all((await flags.forOffer(v.ids)).map(flags.dto)) : null, urls,
notice: req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null,
notice: req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null,
}),
}),
}, { cacheable: true });
}, { cacheable: true });
}
}
- function offerFor(req, res) {
+ async function offerFor(req, res) {
const slug = String(req.params.slug || '');
const slug = String(req.params.slug || '');
const json = slug.endsWith('.json');
const json = slug.endsWith('.json');
- const offer = reads.find(json ? slug.slice(0, -5) : slug);
+ const offer = await reads.find(json ? slug.slice(0, -5) : slug);
if (!offer) { notFound(req, res); return null; }
if (!offer) { notFound(req, res); return null; }
if (offer.merged_into) {
if (offer.merged_into) {
- const root = reads.root(offer);
+ const root = await reads.root(offer);
res.set('Cache-Control', 'public, max-age=300');
res.set('Cache-Control', 'public, max-age=300');
res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);
res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);
return null;
return null;
@@ -205,38 +205,38 @@ function createPages(ctx) {
@@ -205,38 +205,38 @@ function createPages(ctx) {
}
}
router.get('/d/:slug', wrap(async (req, res) => {
router.get('/d/:slug', wrap(async (req, res) => {
- const f = offerFor(req, res);
+ const f = await offerFor(req, res);
if (f) await renderOffer(req, res, f.offer, { json: f.json });
if (f) await renderOffer(req, res, f.offer, { json: f.json });
}));
}));
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
const ip = (req) => req.ip || null;
const ip = (req) => req.ip || null;
- router.post('/d/:slug/vote', B('deals.vote'), form, wrap(async (req, res) => act(req, res, back(req), () => {
+ router.post('/d/:slug/vote', B('deals.vote'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {
const value = { up: 1, down: -1, remove: 0 }[String(req.body.value || '')];
const value = { up: 1, down: -1, remove: 0 }[String(req.body.value || '')];
if (value === undefined) throw new ApiError(422, 'request.invalid', 'Choose hot, cold or remove');
if (value === undefined) throw new ApiError(422, 'request.invalid', 'Choose hot, cold or remove');
- if (value === 0) votes.remove(req.viewer, req.params.slug, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
- else votes.set(req.viewer, req.params.slug, value, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
+ if (value === 0) await votes.remove(req.viewer, req.params.slug, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
+ else await votes.set(req.viewer, req.params.slug, value, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
return `${back(req)}?done=vote`;
return `${back(req)}?done=vote`;
})));
})));
- router.post('/d/:slug/observe', B('deals.offer.observe'), form, wrap(async (req, res) => act(req, res, back(req), () => {
- offers.observe(req.viewer, req.params.slug, req.body, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
+ router.post('/d/:slug/observe', B('deals.offer.observe'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {
+ await offers.observe(req.viewer, req.params.slug, req.body, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
return `${back(req)}?done=observe#history`;
return `${back(req)}?done=observe#history`;
})));
})));
- router.post('/d/:slug/flag', B('deals.flag.create'), form, wrap(async (req, res) => act(req, res, back(req), () => {
- flags.report(req.viewer, req.params.slug, req.body, { ip: ip(req) });
+ router.post('/d/:slug/flag', B('deals.flag.create'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {
+ await flags.report(req.viewer, req.params.slug, req.body, { ip: ip(req) });
return `${back(req)}?done=flag`;
return `${back(req)}?done=flag`;
})));
})));
- router.post('/d/:slug/expire', B('deals.offer.update'), form, wrap(async (req, res) => act(req, res, back(req), () => {
- offers.expire(req.viewer, req.params.slug, {}, { traceparent: req.ov && req.ov.traceparent });
+ router.post('/d/:slug/expire', B('deals.offer.update'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {
+ await offers.expire(req.viewer, req.params.slug, {}, { traceparent: req.ov && req.ov.traceparent });
return `${back(req)}?done=expire`;
return `${back(req)}?done=expire`;
})));
})));
- router.post('/d/:slug/comments', B('deals.comment.create'), form, wrap(async (req, res) => act(req, res, back(req), async () => {
- const root = reads.root(reads.mustFind(req.params.slug));
+ router.post('/d/:slug/comments', B('deals.comment.create'), form, wrap(async (req, res) => await act(req, res, back(req), async () => {
+ const root = await reads.root(await reads.mustFind(req.params.slug));
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed');
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed');
const message = String(req.body.message || '').trim().slice(0, 4000);
const message = String(req.body.message || '').trim().slice(0, 4000);
if (!message) return `${back(req)}#comments`;
if (!message) return `${back(req)}#comments`;
@@ -255,9 +255,9 @@ function createPages(ctx) {
@@ -255,9 +255,9 @@ function createPages(ctx) {
router.get('/p/:slug', wrap(async (req, res) => {
router.get('/p/:slug', wrap(async (req, res) => {
const slug = String(req.params.slug || '');
const slug = String(req.params.slug || '');
const json = slug.endsWith('.json');
const json = slug.endsWith('.json');
- const product = catalog.productBySlug(json ? slug.slice(0, -5) : slug);
+ const product = await catalog.productBySlug(json ? slug.slice(0, -5) : slug);
if (!product) return notFound(req, res);
if (!product) return notFound(req, res);
- const pv = publication.productView(product);
+ const pv = await publication.productView(product);
if (json) {
if (json) {
return sendJson(req, res, 200, {
return sendJson(req, res, 200, {
product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
@@ -276,13 +276,13 @@ function createPages(ctx) {
@@ -276,13 +276,13 @@ function createPages(ctx) {
}));
}));
router.get('/s/:domain', wrap(async (req, res) => {
router.get('/s/:domain', wrap(async (req, res) => {
- const st = catalog.storeByDomain(req.params.domain);
+ const st = await catalog.storeByDomain(req.params.domain);
if (!st) return notFound(req, res);
if (!st) return notFound(req, res);
const path = publication.storePath(st);
const path = publication.storePath(st);
- const total = listings.countStore(st.id);
+ const total = await listings.countStore(st.id);
const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
if (pager.outOfRange && total) return notFound(req, res);
if (pager.outOfRange && total) return notFound(req, res);
- const list = listings.byStore(st.id, PER_PAGE, pager.offset).map((o) => publication.offerView(o));
+ const list = await Promise.all((await listings.byStore(st.id, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
send(req, res, 200, {
send(req, res, 200, {
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
@@ -296,12 +296,12 @@ function createPages(ctx) {
@@ -296,12 +296,12 @@ function createPages(ctx) {
router.get('/search', wrap(async (req, res) => {
router.get('/search', wrap(async (req, res) => {
const q = String(req.query.q || '').slice(0, 200);
const q = String(req.query.q || '').slice(0, 200);
const page = pageNumber(req);
const page = pageNumber(req);
- const r = q ? listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };
+ const r = q ? await listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };
const pager = q ? ssr.paginate({ page, perPage: PER_PAGE, total: r.total, href: (p) => `/search?q=${encodeURIComponent(q)}&page=${p}` }) : null;
const pager = q ? ssr.paginate({ page, perPage: PER_PAGE, total: r.total, href: (p) => `/search?q=${encodeURIComponent(q)}&page=${p}` }) : null;
send(req, res, 200, {
send(req, res, 200, {
title: q ? `Search: ${q}` : 'Search deals',
title: q ? `Search: ${q}` : 'Search deals',
decision: pageDecision('/search', { indexable: false }),
decision: pageDecision('/search', { indexable: false }),
- body: views.searchPage({ q, results: r.rows.map((o) => publication.offerView(o)), pager, urls, csrf: csrf(req), signedIn: signedIn(req) }),
+ body: views.searchPage({ q, results: await Promise.all(r.rows.map(async (o) => await publication.offerView(o))), pager, urls, csrf: csrf(req), signedIn: signedIn(req) }),
});
});
}));
}));
@@ -325,7 +325,7 @@ function createPages(ctx) {
@@ -325,7 +325,7 @@ function createPages(ctx) {
product: b.product_name || b.product_gtin ? { name: b.product_name, gtin: b.product_gtin } : null,
product: b.product_name || b.product_gtin ? { name: b.product_name, gtin: b.product_gtin } : null,
};
};
try {
try {
- const { offer } = offers.submit(req.viewer, input, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
+ const { offer } = await offers.submit(req.viewer, input, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
res.redirect(303, publication.offerPath(offer));
res.redirect(303, publication.offerPath(offer));
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError)) throw err;
if (!(err instanceof ApiError)) throw err;
@@ -336,30 +336,30 @@ function createPages(ctx) {
@@ -336,30 +336,30 @@ function createPages(ctx) {
// ── watches ─────────────────────────────────────────────
// ── watches ─────────────────────────────────────────────
- function watchesPage(req, res, status = 200, { error, notice } = {}) {
- const list = watches.list(req.viewer.subject).map(watches.dto);
+ async function watchesPage(req, res, status = 200, { error, notice } = {}) {
+ const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));
send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls }) });
send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls }) });
}
}
router.get('/watches', wrap(async (req, res) => {
router.get('/watches', wrap(async (req, res) => {
if (!signedIn(req)) return send(req, res, 200, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.message({ heading: 'Watches', text: 'Sign in to watch keywords, products and prices, and to save searches.', action: { href: '/auth/login?next=%2Fwatches', label: 'Sign in' } }) });
if (!signedIn(req)) return send(req, res, 200, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.message({ heading: 'Watches', text: 'Sign in to watch keywords, products and prices, and to save searches.', action: { href: '/auth/login?next=%2Fwatches', label: 'Sign in' } }) });
- watchesPage(req, res);
+ await watchesPage(req, res);
}));
}));
router.post('/watches', B('deals.watch'), form, wrap(async (req, res) => {
router.post('/watches', B('deals.watch'), form, wrap(async (req, res) => {
if (!formGuard(req, res, '/watches')) return;
if (!formGuard(req, res, '/watches')) return;
try {
try {
const b = req.body || {};
const b = req.body || {};
- watches.create(req.viewer.subject, { kind: b.kind, query: b.query, product: b.product || null, max_price: b.max_price, currency: b.currency, label: b.label });
+ await watches.create(req.viewer.subject, { kind: b.kind, query: b.query, product: b.product || null, max_price: b.max_price, currency: b.currency, label: b.label });
res.redirect(303, '/watches');
res.redirect(303, '/watches');
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError)) throw err;
if (!(err instanceof ApiError)) throw err;
- watchesPage(req, res, err.status, { error: err.message });
+ await watchesPage(req, res, err.status, { error: err.message });
}
}
}));
}));
- router.post('/watches/:id/delete', B('deals.watch'), form, wrap(async (req, res) => act(req, res, '/watches', () => {
- watches.remove(req.viewer.subject, req.params.id);
+ router.post('/watches/:id/delete', B('deals.watch'), form, wrap(async (req, res) => await act(req, res, '/watches', async () => {
+ await watches.remove(req.viewer.subject, req.params.id);
return '/watches';
return '/watches';
})));
})));
@@ -373,62 +373,62 @@ function createPages(ctx) {
@@ -373,62 +373,62 @@ function createPages(ctx) {
router.get('/mod', wrap(async (req, res) => {
router.get('/mod', wrap(async (req, res) => {
if (!modGuard(req, res)) return;
if (!modGuard(req, res)) return;
- const dups = listings.possibleDuplicates().map((d) => ({ a: reads.get(d.a), b: reads.get(d.b) }));
+ const dups = await Promise.all((await listings.possibleDuplicates()).map(async (d) => ({ a: await reads.get(d.a), b: await reads.get(d.b) })));
send(req, res, 200, {
send(req, res, 200, {
title: 'Moderation', decision: pageDecision('/mod', { indexable: false }),
title: 'Moderation', decision: pageDecision('/mod', { indexable: false }),
- body: views.modPage({ csrf: csrf(req), flags: flags.list({ status: 'open' }).map(flags.dto), pending: listings.pendingReview(), duplicates: dups, urls }),
+ body: views.modPage({ csrf: csrf(req), flags: await Promise.all((await flags.list({ status: 'open' })).map(flags.dto)), pending: await listings.pendingReview(), duplicates: dups, urls }),
});
});
}));
}));
router.post('/mod/offers/:slug/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {
router.post('/mod/offers/:slug/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {
if (!modGuard(req, res)) return;
if (!modGuard(req, res)) return;
const tp = { traceparent: req.ov && req.ov.traceparent };
const tp = { traceparent: req.ov && req.ov.traceparent };
- await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, () => {
+ await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, async () => {
const b = req.body || {};
const b = req.body || {};
switch (req.params.action) {
switch (req.params.action) {
- case 'merge': { const out = offers.merge(req.viewer, req.params.slug, b.into, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }
- case 'unmerge': { const out = offers.unmerge(req.viewer, req.params.slug, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }
- case 'disable': offers.disable(req.viewer, req.params.slug, { reason: b.reason }, tp); return '/mod';
- case 'enable': offers.enable(req.viewer, req.params.slug, { reason: b.reason }, tp); break;
- case 'review': offers.review(req.viewer, req.params.slug, { note: b.note }, tp); break;
- case 'expire': offers.expire(req.viewer, req.params.slug, { reason: b.reason }, tp); break;
+ case 'merge': { const out = await offers.merge(req.viewer, req.params.slug, b.into, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }
+ case 'unmerge': { const out = await offers.unmerge(req.viewer, req.params.slug, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }
+ case 'disable': await offers.disable(req.viewer, req.params.slug, { reason: b.reason }, tp); return '/mod';
+ case 'enable': await offers.enable(req.viewer, req.params.slug, { reason: b.reason }, tp); break;
+ case 'review': await offers.review(req.viewer, req.params.slug, { note: b.note }, tp); break;
+ case 'expire': await offers.expire(req.viewer, req.params.slug, { reason: b.reason }, tp); break;
default: throw new ApiError(404, 'route.not_found', 'No such action');
default: throw new ApiError(404, 'route.not_found', 'No such action');
}
}
- return `/d/${encodeURIComponent(reads.root(reads.mustFind(req.params.slug)).slug)}?done=moderated`;
+ return `/d/${encodeURIComponent((await reads.root(await reads.mustFind(req.params.slug))).slug)}?done=moderated`;
});
});
}));
}));
router.post('/mod/flags/:id/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {
router.post('/mod/flags/:id/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {
if (!modGuard(req, res)) return;
if (!modGuard(req, res)) return;
- await act(req, res, '/mod', () => {
+ await act(req, res, '/mod', async () => {
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
if (!status) throw new ApiError(404, 'route.not_found', 'No such action');
if (!status) throw new ApiError(404, 'route.not_found', 'No such action');
- flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution }, { traceparent: req.ov && req.ov.traceparent });
+ await flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution }, { traceparent: req.ov && req.ov.traceparent });
return '/mod';
return '/mod';
});
});
}));
}));
// ── feeds (never viewer-dependent) ──────────────────────
// ── feeds (never viewer-dependent) ──────────────────────
- function feedItems() {
- return listings.newest(50, 0).map((o) => {
- const v = publication.offerView(o);
+ async function feedItems() {
+ return Promise.all((await listings.newest(50, 0)).map(async (o) => {
+ const v = await publication.offerView(o);
const price = `${views.priceText(v.latest)}${v.latest ? ` as of ${iso(v.latest.observed_at)}` : ''}`;
const price = `${views.priceText(v.latest)}${v.latest ? ` as of ${iso(v.latest.observed_at)}` : ''}`;
return {
return {
id: `deals:offer:${o.id}`, url: publication.abs(publication.offerPath(o)), title: o.title,
id: `deals:offer:${o.id}`, url: publication.abs(publication.offerPath(o)), title: o.title,
summary: `${price}${v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${o.description ? ` ${o.description.slice(0, 300)}` : ''}`,
summary: `${price}${v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${o.description ? ` ${o.description.slice(0, 300)}` : ''}`,
published: o.created_at, updated: Math.max(o.updated_at, v.latest ? v.latest.observed_at : 0), decision: v.decision,
published: o.created_at, updated: Math.max(o.updated_at, v.latest ? v.latest.observed_at : 0), decision: v.decision,
};
};
- });
+ }));
}
}
const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });
const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });
- router.get('/feed.xml', (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, feedItems())));
- router.get('/atom.xml', (_req, res) => {
- const items = feedItems();
+ router.get('/feed.xml', async (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, await feedItems())));
+ router.get('/atom.xml', async (_req, res) => {
+ const items = await feedItems();
res.type('application/atom+xml').set('Cache-Control', 'public, max-age=300').send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));
res.type('application/atom+xml').set('Cache-Control', 'public, max-age=300').send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));
});
});
- router.get('/feed.json', (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, feedItems()))));
+ router.get('/feed.json', async (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, await feedItems()))));
return { router, notFound, messagePage };
return { router, notFound, messagePage };
}
}
@@ -7,11 +7,12 @@
@@ -7,11 +7,12 @@
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
-const { app, ctx } = createApp();
+(async () => {
+const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
- console.log(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.enabled ? 'on' : 'off'}`);
console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.enabled ? 'on' : 'off'}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
@@ -23,10 +24,11 @@ function shutdown(signal) {
@@ -23,10 +24,11 @@ function shutdown(signal) {
ctx.worker.stop();
ctx.worker.stop();
server.close(async () => {
server.close(async () => {
try { await ctx.outbox.stop(); } catch { /* best effort */ }
try { await ctx.outbox.stop(); } catch { /* best effort */ }
- try { ctx.store.close(); } catch { /* already closed */ }
+ try { await ctx.store.close(); } catch { /* already closed */ }
process.exit(0);
process.exit(0);
});
});
setTimeout(() => process.exit(0), 5000).unref();
setTimeout(() => process.exit(0), 5000).unref();
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
+})().catch((err) => { console.error('[Deals] failed to start:', err); process.exit(1); });
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
const { CHARTER_TABLES } = require('./db');
const { CHARTER_TABLES } = require('./db');
-function createDealsReadiness({ store, auth, outbox, importer, worker, limits, release = null }) {
+function createDealsReadiness({ store, auth, outbox, importer, worker, limits, valkey = null, release = null }) {
const { db } = store;
const { db } = store;
return createReadiness({
return createReadiness({
service: 'deals',
service: 'deals',
@@ -24,12 +24,16 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r
@@ -24,12 +24,16 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r
checks: [
checks: [
{
{
name: 'db', required: true,
name: 'db', required: true,
- check: () => {
- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
+ check: async () => {
+ // A real round trip that names the store (postgresql / pglite), and the charter tables present.
+ const r = await db.ready();
+ if (!r.ok) return r.error;
+ const names = new Set((await db.prepare('SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()').all()).map((x) => x.name));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
- return missing.length ? `missing ${missing.join(', ')}` : true;
+ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
},
},
},
},
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
@@ -40,8 +44,8 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r
@@ -40,8 +44,8 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r
},
},
{
{
name: 'events_relay', required: false,
name: 'events_relay', required: false,
- check: () => {
- const s = outbox.status();
+ check: async () => {
+ const s = await outbox.status();
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
return { ok: true, detail: { pending: s.pending } };
return { ok: true, detail: { pending: s.pending } };
@@ -49,8 +53,8 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r
@@ -49,8 +53,8 @@ function createDealsReadiness({ store, auth, outbox, importer, worker, limits, r
},
},
{
{
name: 'sources_import', required: false,
name: 'sources_import', required: false,
- check: () => {
- const s = importer.status();
+ check: async () => {
+ const s = await importer.status();
if (!s.enabled) return 'import off (DEALS_IMPORT=off, or OV_OAUTH_CLIENT_SECRET / OV_SOURCES_INTERNAL_URL unset)';
if (!s.enabled) return 'import off (DEALS_IMPORT=off, or OV_OAUTH_CLIENT_SECRET / OV_SOURCES_INTERNAL_URL unset)';
if (s.last_error) return `last pull failed: ${s.last_error}`;
if (s.last_error) return `last pull failed: ${s.last_error}`;
return { ok: true, detail: { cursor: s.cursor, last_run_at: s.last_run_at } };
return { ok: true, detail: { cursor: s.cursor, last_run_at: s.last_run_at } };
@@ -24,17 +24,17 @@ function createWorker({ config, store, offers, hotness, indexing, listings, impo
@@ -24,17 +24,17 @@ function createWorker({ config, store, offers, hotness, indexing, listings, impo
running = true;
running = true;
const summary = {};
const summary = {};
try {
try {
- summary.expired = offers.expireDue();
- summary.snapshots = hotness.tick({ windowDays: config.worker.hotWindowDays });
+ summary.expired = await offers.expireDue();
+ summary.snapshots = await hotness.tick({ windowDays: config.worker.hotWindowDays });
let reindexed = 0;
let reindexed = 0;
- for (const o of listings.indexable()) {
- store.tx(() => { if (indexing.indexOffer(o)) reindexed++; });
+ for (const o of await listings.indexable()) {
+ await store.tx(async () => { if (await indexing.indexOffer(o)) reindexed++; });
}
}
- for (const p of listings.products()) store.tx(() => { if (indexing.indexProduct(p)) reindexed++; });
+ for (const p of await listings.products()) await store.tx(async () => { if (await indexing.indexProduct(p)) reindexed++; });
summary.reindexed = reindexed;
summary.reindexed = reindexed;
summary.import = await importer.pull();
summary.import = await importer.pull();
summary.refresh = await importer.refresh();
summary.refresh = await importer.refresh();
- summary.pruned = limits.prune();
+ summary.pruned = await limits.prune();
lastError = null;
lastError = null;
} catch (err) {
} catch (err) {
lastError = err.message;
lastError = err.message;
@@ -28,7 +28,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -28,7 +28,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
assert.strictEqual(r.status, 429);
assert.strictEqual(r.status, 429);
assert.strictEqual(r.json().code, 'vote.rate_limited');
assert.strictEqual(r.json().code, 'vote.rate_limited');
assert.ok(Number(r.headers.get('retry-after')) > 0);
assert.ok(Number(r.headers.get('retry-after')) > 0);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject).n, 4);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject)).n, 4);
t.clock.advance(HOUR + 1000);
t.clock.advance(HOUR + 1000);
assert.strictEqual((await vote(u, offers[4], 1, '198.51.100.9')).status, 200);
assert.strictEqual((await vote(u, offers[4], 1, '198.51.100.9')).status, 200);
});
});
@@ -47,12 +47,12 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -47,12 +47,12 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
const codes = [];
const codes = [];
for (let i = 0; i < 6; i++) codes.push((await vote(t.network.addUser(`ipuser${i}`), offers[6], 1, ip)).status);
for (let i = 0; i < 6; i++) codes.push((await vote(t.network.addUser(`ipuser${i}`), offers[6], 1, ip)).status);
assert.deepStrictEqual(codes, [200, 200, 200, 200, 200, 429]);
assert.deepStrictEqual(codes, [200, 200, 200, 200, 200, 429]);
- const dump = JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM deal_votes').all()) + JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM rate_events').all()) + JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM deal_flags').all());
+ const dump = JSON.stringify(await t.ctx.store.db.prepare('SELECT * FROM deal_votes').all()) + JSON.stringify(await t.ctx.store.db.prepare('SELECT * FROM rate_events').all()) + JSON.stringify(await t.ctx.store.db.prepare('SELECT * FROM deal_flags').all());
assert.ok(!dump.includes(ip), 'no raw IP in votes, rate windows or flags');
assert.ok(!dump.includes(ip), 'no raw IP in votes, rate windows or flags');
});
});
await check('five voters from one IP hash on one offer raise ONE open shared_ip vote-ring flag', async () => {
await check('five voters from one IP hash on one offer raise ONE open shared_ip vote-ring flag', async () => {
- const flags = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'shared_ip'").all();
+ const flags = await t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'shared_ip'").all();
assert.strictEqual(flags.length, 1);
assert.strictEqual(flags.length, 1);
assert.strictEqual(flags[0].status, 'open');
assert.strictEqual(flags[0].status, 'open');
assert.strictEqual(flags[0].offer_id, offers[6].id);
assert.strictEqual(flags[0].offer_id, offers[6].id);
@@ -79,7 +79,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -79,7 +79,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
t.clock.advance(60 * 1000);
t.clock.advance(60 * 1000);
assert.strictEqual((await vote(b, offers[i], 1, `10.1.${i}.1`)).status, 200);
assert.strictEqual((await vote(b, offers[i], 1, `10.1.${i}.1`)).status, 200);
}
}
- const flags = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'covote' AND status = 'open'").all();
+ const flags = await t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'covote' AND status = 'open'").all();
assert.strictEqual(flags.length, 1);
assert.strictEqual(flags.length, 1);
const d = JSON.parse(flags[0].details);
const d = JSON.parse(flags[0].details);
assert.deepStrictEqual(d.voters, [a.subject, b.subject].sort());
assert.deepStrictEqual(d.voters, [a.subject, b.subject].sort());
@@ -105,19 +105,19 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -105,19 +105,19 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
});
});
await check('a moderator resolves a flag (audited); flags never change votes by themselves', async () => {
await check('a moderator resolves a flag (audited); flags never change votes by themselves', async () => {
- const f = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE reason = 'shared_ip'").get();
- const votesBefore = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n;
+ const f = await t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE reason = 'shared_ip'").get();
+ const votesBefore = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get()).n;
const r = await t.get(`/mod/flags/${f.id}/resolve`, { as: t.mod, form: { resolution: 'checked' } });
const r = await t.get(`/mod/flags/${f.id}/resolve`, { as: t.mod, form: { resolution: 'checked' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT status FROM deal_flags WHERE id = ?').get(f.id).status, 'resolved');
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n, votesBefore);
- assert.ok(t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'flag.resolve'").get());
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT status FROM deal_flags WHERE id = ?').get(f.id)).status, 'resolved');
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get()).n, votesBefore);
+ assert.ok(await t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'flag.resolve'").get());
});
});
await check('a person\'s repeated report updates their open flag instead of adding another', async () => {
await check('a person\'s repeated report updates their open flag instead of adding another', async () => {
const u = t.network.addUser('reporter');
const u = t.network.addUser('reporter');
for (let i = 0; i < 3; i++) assert.strictEqual((await t.get(`/d/${offers[1].slug}/flag`, { as: u, form: { kind: 'expired', reason: `try ${i}` } })).status, 303);
for (let i = 0; i < 3; i++) assert.strictEqual((await t.get(`/d/${offers[1].slug}/flag`, { as: u, form: { kind: 'expired', reason: `try ${i}` } })).status, 303);
- const rows = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE reporter = ?').all(u.subject);
+ const rows = await t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE reporter = ?').all(u.subject);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].reason, 'try 2');
assert.strictEqual(rows[0].reason, 'try 2');
});
});
@@ -134,7 +134,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -134,7 +134,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
const u = t.network.addUser('csrf');
const u = t.network.addUser('csrf');
const r = await t.get(`/d/${offers[3].slug}/vote`, { as: u, form: { value: 'up', csrf: 'forged' } });
const r = await t.get(`/d/${offers[3].slug}/vote`, { as: u, form: { value: 'up', csrf: 'forged' } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject).n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject)).n, 0);
});
});
await t.close();
await t.close();
@@ -54,16 +54,16 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
@@ -54,16 +54,16 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
await check('a write has its own budget (30 watch changes a minute), shared by the API and the form; nothing stored past it', async () => {
await check('a write has its own budget (30 watch changes a minute), shared by the API and the form; nothing stored past it', async () => {
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
- const stored = (u) => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_watches WHERE subject = ?').get(u.subject).n;
+ const stored = async (u) => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_watches WHERE subject = ?').get(u.subject)).n;
for (let i = 0; i < 30; i++) {
for (let i = 0; i < 30; i++) {
const r = await t.get('/api/v1/watches', { as: ann, json: { kind: 'keyword', query: `thing ${i}` } });
const r = await t.get('/api/v1/watches', { as: ann, json: { kind: 'keyword', query: `thing ${i}` } });
assert.strictEqual(r.status, 201, `watch ${i + 1}: ${r.text}`);
assert.strictEqual(r.status, 201, `watch ${i + 1}: ${r.text}`);
}
}
- const before = stored(ann);
+ const before = await stored(ann);
const r = await t.get('/watches', { as: ann, form: { kind: 'keyword', query: 'one more' } });
const r = await t.get('/watches', { as: ann, form: { kind: 'keyword', query: 'one more' } });
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.ok(r.json().detail.includes('deals.watch'), r.json().detail);
assert.ok(r.json().detail.includes('deals.watch'), r.json().detail);
- assert.strictEqual(stored(ann), before, 'nothing stored');
+ assert.strictEqual(await stored(ann), before, 'nothing stored');
assert.strictEqual((await t.get('/api/v1/watches', { as: ben, json: { kind: 'keyword', query: 'mine' } })).status, 201, 'another person still writes');
assert.strictEqual((await t.get('/api/v1/watches', { as: ben, json: { kind: 'keyword', query: 'mine' } })).status, 201, 'another person still writes');
});
});
@@ -134,7 +134,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -134,7 +134,7 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('every event validates as events.event-envelope@1; every Search document as search.index-document@1', async () => {
await check('every event validates as events.event-envelope@1; every Search document as search.index-document@1', async () => {
- const all = t.events();
+ const all = await t.events();
assert.ok(all.length > 10);
assert.ok(all.length > 10);
const types = new Set();
const types = new Set();
for (const e of all) {
for (const e of all) {
@@ -60,16 +60,16 @@ const { sourceItem } = require('./helpers/mocks');
@@ -60,16 +60,16 @@ const { sourceItem } = require('./helpers/mocks');
});
});
await check('the worker re-indexes a price that went stale: one new Search document (noindex), then nothing', async () => {
await check('the worker re-indexes a price that went stale: one new Search document (noindex), then nothing', async () => {
- const before = t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id);
+ const before = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === offer.id);
await t.ctx.worker.tick();
await t.ctx.worker.tick();
- const after = t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id);
+ const after = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === offer.id);
assert.strictEqual(after.length, before.length + 1);
assert.strictEqual(after.length, before.length + 1);
const doc = after[after.length - 1].payload;
const doc = after[after.length - 1].payload;
assert.strictEqual(doc.facets.freshness, 'stale');
assert.strictEqual(doc.facets.freshness, 'stale');
assert.strictEqual(doc.indexability.decision, 'noindex');
assert.strictEqual(doc.indexability.decision, 'noindex');
assert.ok(doc.revision > before[before.length - 1].payload.revision);
assert.ok(doc.revision > before[before.length - 1].payload.revision);
await t.ctx.worker.tick();
await t.ctx.worker.tick();
- assert.strictEqual(t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id).length, after.length);
+ assert.strictEqual((await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === offer.id).length, after.length);
});
});
await check('a new observation makes it fresh again, and the history keeps both, newest first', async () => {
await check('a new observation makes it fresh again, and the history keeps both, newest first', async () => {
@@ -88,7 +88,7 @@ const { sourceItem } = require('./helpers/mocks');
@@ -88,7 +88,7 @@ const { sourceItem } = require('./helpers/mocks');
await check('an imported observation is dated by when the source saw it: old retrieval → stale from the start', async () => {
await check('an imported observation is dated by when the source saw it: old retrieval → stale from the start', async () => {
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ1', title: 'Old TV offer', url: 'https://tv.example/old', fields: { price: '300', currency: 'EUR' }, retrievedAt: t.clock.now() - 72 * HOUR }));
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ1', title: 'Old TV offer', url: 'https://tv.example/old', fields: { price: '300', currency: 'EUR' }, retrievedAt: t.clock.now() - 72 * HOUR }));
await t.ctx.importer.pull();
await t.ctx.importer.pull();
- const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://tv.example/old'").get();
+ const o = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://tv.example/old'").get();
const json = await t.offerJson(o.slug);
const json = await t.offerJson(o.slug);
assert.strictEqual(json.latest_observation.observed_at, new Date(t.clock.now() - 72 * HOUR).toISOString());
assert.strictEqual(json.latest_observation.observed_at, new Date(t.clock.now() - 72 * HOUR).toISOString());
assert.strictEqual(json.freshness.state, 'stale');
assert.strictEqual(json.freshness.state, 'stale');
@@ -44,11 +44,14 @@ async function boot(opts = {}) {
@@ -44,11 +44,14 @@ async function boot(opts = {}) {
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
+ const { createStore } = require('../../server/db');
+ // One database per boot (PGlite, or DEALS_TEST_STORE=pg: the containers); a restart keeps it, like a file did.
+ const testdb = await require('./db').testDb();
let server = null;
let server = null;
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = createApp({ config, now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
await built.ctx.auth.ensureKey();
await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;
@@ -57,7 +60,7 @@ async function boot(opts = {}) {
@@ -57,7 +60,7 @@ async function boot(opts = {}) {
}
}
async function stop() {
async function stop() {
if (server) await new Promise((r) => server.close(r));
if (server) await new Promise((r) => server.close(r));
- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
+ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }
server = null; built = null;
server = null; built = null;
}
}
@@ -81,8 +84,8 @@ async function boot(opts = {}) {
@@ -81,8 +84,8 @@ async function boot(opts = {}) {
}
}
/** Rows of event_outbox as parsed envelopes. */
/** Rows of event_outbox as parsed envelopes. */
- function events(type = null) {
- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
+ async function events(type = null) {
+ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))
.filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
.filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
}
}
@@ -91,7 +94,7 @@ async function boot(opts = {}) {
@@ -91,7 +94,7 @@ async function boot(opts = {}) {
const r = await get('/submit', { as: user, form: fields, ip });
const r = await get('/submit', { as: user, form: fields, ip });
if (r.status !== 303) throw new Error(`submit answered ${r.status}: ${r.text.slice(0, 400)}`);
if (r.status !== 303) throw new Error(`submit answered ${r.status}: ${r.text.slice(0, 400)}`);
const slug = decodeURIComponent(r.headers.get('location').replace(/^\/d\//, '').replace(/\?.*$/, ''));
const slug = decodeURIComponent(r.headers.get('location').replace(/^\/d\//, '').replace(/\?.*$/, ''));
- return t.ctx.reads.find(slug);
+ return await t.ctx.reads.find(slug);
}
}
function offerJson(slug) { return get(`/d/${slug}.json`).then((r) => r.json().offer); }
function offerJson(slug) { return get(`/d/${slug}.json`).then((r) => r.json().offer); }
@@ -100,7 +103,7 @@ async function boot(opts = {}) {
@@ -100,7 +103,7 @@ async function boot(opts = {}) {
network, community, sources, clock, dbPath, mod, get, events, submit, offerJson, HOUR,
network, community, sources, clock, dbPath, mod, get, events, submit, offerJson, HOUR,
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.DEALS_FORM_SECRET }, user),
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.DEALS_FORM_SECRET }, user),
async restart() { await stop(); await start(); },
async restart() { await stop(); await start(); },
- async close() { await stop(); await network.close(); await community.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
+ async close() { await stop(); await testdb.close(); await network.close(); await community.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
};
};
await start();
await start();
return t;
return t;
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * DEALS_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.DEALS_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'deals', max });
+
+module.exports = { testDb, pgAvailable };
@@ -28,47 +28,47 @@ const { formula } = require('../server/domain/hotness');
@@ -28,47 +28,47 @@ const { formula } = require('../server/domain/hotness');
const plan = [[users[0], 'up'], [users[1], 'up'], [users[2], 'down'], [users[3], 'up']];
const plan = [[users[0], 'up'], [users[1], 'up'], [users[2], 'down'], [users[3], 'up']];
for (const [u, v] of plan) await t.get(`/d/${X.slug}/vote`, { as: u, form: { value: v } });
for (const [u, v] of plan) await t.get(`/d/${X.slug}/vote`, { as: u, form: { value: v } });
for (const [u, v] of [...plan].reverse()) await t.get(`/d/${Y.slug}/vote`, { as: u, form: { value: v } });
for (const [u, v] of [...plan].reverse()) await t.get(`/d/${Y.slug}/vote`, { as: u, form: { value: v } });
- const sx = t.ctx.reads.lastSnapshot(X.id);
- const sy = t.ctx.reads.lastSnapshot(Y.id);
+ const sx = await t.ctx.reads.lastSnapshot(X.id);
+ const sy = await t.ctx.reads.lastSnapshot(Y.id);
for (const k of ['formula', 'up_count', 'down_count', 'up_weight', 'down_weight', 'score', 'hot']) assert.strictEqual(sx[k], sy[k], k);
for (const k of ['formula', 'up_count', 'down_count', 'up_weight', 'down_weight', 'score', 'hot']) assert.strictEqual(sx[k], sy[k], k);
assert.strictEqual(sx.score, 2);
assert.strictEqual(sx.score, 2);
assert.strictEqual(sx.hot, formula({ upWeight: 3, downWeight: 1, firstSeenAt: X.created_at, t: sx.computed_at }).hot);
assert.strictEqual(sx.hot, formula({ upWeight: 3, downWeight: 1, firstSeenAt: X.created_at, t: sx.computed_at }).hot);
});
});
await check('every stored snapshot recomputes to itself (GET /api/v1/offers/:id/hotness)', async () => {
await check('every stored snapshot recomputes to itself (GET /api/v1/offers/:id/hotness)', async () => {
- const rows = t.ctx.store.db.prepare('SELECT * FROM deal_hotness_snapshots').all();
+ const rows = await t.ctx.store.db.prepare('SELECT * FROM deal_hotness_snapshots').all();
assert.ok(rows.length >= 6);
assert.ok(rows.length >= 6);
for (const s of rows) assert.strictEqual(t.ctx.hotness.recompute(s).hot, s.hot, `snapshot ${s.id}`);
for (const s of rows) assert.strictEqual(t.ctx.hotness.recompute(s).hot, s.hot, `snapshot ${s.id}`);
- const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
+ const X = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
const r = (await t.get(`/api/v1/offers/${X.slug}/hotness`)).json();
const r = (await t.get(`/api/v1/offers/${X.slug}/hotness`)).json();
assert.strictEqual(r.recomputed.hot, r.snapshot.hot);
assert.strictEqual(r.recomputed.hot, r.snapshot.hot);
assert.match(r.formula, /hot@1/);
assert.match(r.formula, /hot@1/);
});
});
await check('clients cannot set weight, score or hotness: only value is read', async () => {
await check('clients cannot set weight, score or hotness: only value is read', async () => {
- const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
- const before = t.ctx.reads.lastSnapshot(X.id);
+ const X = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
+ const before = await t.ctx.reads.lastSnapshot(X.id);
const r = await t.get(`/api/v1/offers/${X.id}/vote`, { method: 'PUT', as: users[4], json: { value: 1, weight: 100, score: 999, hot: 999 } });
const r = await t.get(`/api/v1/offers/${X.id}/vote`, { method: 'PUT', as: users[4], json: { value: 1, weight: 100, score: 999, hot: 999 } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- const row = t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ? AND offer_id = ?').get(users[4].subject, X.id);
+ const row = await t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ? AND offer_id = ?').get(users[4].subject, X.id);
assert.strictEqual(row.weight, 1);
assert.strictEqual(row.weight, 1);
- const after = t.ctx.reads.lastSnapshot(X.id);
+ const after = await t.ctx.reads.lastSnapshot(X.id);
assert.strictEqual(after.up_weight, before.up_weight + 1);
assert.strictEqual(after.up_weight, before.up_weight + 1);
});
});
await check('accounts new to OpenVibe count 0.25 until there is evidence they are a week old', async () => {
await check('accounts new to OpenVibe count 0.25 until there is evidence they are a week old', async () => {
- const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
+ const X = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
const fresh = t.network.addUser('newbie', { mintedAt: t.clock.now() - 2 * 24 * HOUR });
const fresh = t.network.addUser('newbie', { mintedAt: t.clock.now() - 2 * 24 * HOUR });
await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'up' } });
await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'up' } });
- const row = t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject);
+ const row = await t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject);
assert.strictEqual(row.weight, 0.25);
assert.strictEqual(row.weight, 0.25);
- const snap = t.ctx.reads.lastSnapshot(X.id);
+ const snap = await t.ctx.reads.lastSnapshot(X.id);
assert.strictEqual(snap.up_count, 5);
assert.strictEqual(snap.up_count, 5);
assert.strictEqual(snap.up_weight, 4.25);
assert.strictEqual(snap.up_weight, 4.25);
// Six days later the subject id proves ≥ 7 days: a new vote carries weight 1.
// Six days later the subject id proves ≥ 7 days: a new vote carries weight 1.
t.clock.advance(6 * 24 * HOUR);
t.clock.advance(6 * 24 * HOUR);
await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'down' } });
await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'down' } });
- assert.strictEqual(t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject).weight, 1);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject)).weight, 1);
});
});
await check('the worker snapshots every active offer at one shared t; the hot list follows those snapshots', async () => {
await check('the worker snapshots every active offer at one shared t; the hot list follows those snapshots', async () => {
@@ -76,7 +76,7 @@ const { formula } = require('../server/domain/hotness');
@@ -76,7 +76,7 @@ const { formula } = require('../server/domain/hotness');
for (const u of users.slice(0, 5)) await t.get(`/d/${Z.slug}/vote`, { as: u, form: { value: 'up' } });
for (const u of users.slice(0, 5)) await t.get(`/d/${Z.slug}/vote`, { as: u, form: { value: 'up' } });
t.clock.advance(HOUR);
t.clock.advance(HOUR);
await t.ctx.worker.tick();
await t.ctx.worker.tick();
- const ticks = t.ctx.store.db.prepare("SELECT offer_id, computed_at, hot FROM deal_hotness_snapshots WHERE reason = 'tick'").all();
+ const ticks = await t.ctx.store.db.prepare("SELECT offer_id, computed_at, hot FROM deal_hotness_snapshots WHERE reason = 'tick'").all();
assert.strictEqual(new Set(ticks.map((x) => x.computed_at)).size, 1);
assert.strictEqual(new Set(ticks.map((x) => x.computed_at)).size, 1);
assert.strictEqual(ticks.length, 3);
assert.strictEqual(ticks.length, 3);
const list = await t.get('/api/v1/offers?sort=hot');
const list = await t.get('/api/v1/offers?sort=hot');
@@ -11,8 +11,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -11,8 +11,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
- const byUrl = (u) => t.ctx.store.db.prepare('SELECT * FROM deal_offers WHERE url = ?').get(u);
- const obsOf = (id) => t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at, rowid').all(id);
+ const byUrl = async (u) => await t.ctx.store.db.prepare('SELECT * FROM deal_offers WHERE url = ?').get(u);
+ const obsOf = async (id) => await t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at, seq').all(id);
const T0 = t.clock.now();
const T0 = t.clock.now();
await check('offer, product and article items become offers; values exactly as stated; observed_at = retrieved_at', async () => {
await check('offer, product and article items become offers; values exactly as stated; observed_at = retrieved_at', async () => {
@@ -28,20 +28,20 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -28,20 +28,20 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
assert.deepStrictEqual(s.outcomes, { created: 4 }, JSON.stringify(s));
assert.deepStrictEqual(s.outcomes, { created: 4 }, JSON.stringify(s));
assert.strictEqual(s.items, 3);
assert.strictEqual(s.items, 3);
- const esp = byUrl('https://coffee.example/espresso');
+ const esp = await byUrl('https://coffee.example/espresso');
assert.strictEqual(esp.origin, 'import');
assert.strictEqual(esp.origin, 'import');
assert.strictEqual(esp.review_state, 'pending');
assert.strictEqual(esp.review_state, 'pending');
assert.strictEqual(esp.expires_at, Date.parse('2026-10-01'));
assert.strictEqual(esp.expires_at, Date.parse('2026-10-01'));
- const o = obsOf(esp.id)[0];
+ const o = (await obsOf(esp.id))[0];
assert.deepStrictEqual([o.price, o.currency, o.availability, o.condition, o.observed_at, o.origin], ['249.00', 'EUR', 'in_stock', 'refurbished', T0 - HOUR, 'import']);
assert.deepStrictEqual([o.price, o.currency, o.availability, o.condition, o.observed_at, o.origin], ['249.00', 'EUR', 'in_stock', 'refurbished', T0 - HOUR, 'import']);
- assert.strictEqual(t.ctx.catalog.store(esp.store_id).name, 'Coffee Shop');
+ assert.strictEqual((await t.ctx.catalog.store(esp.store_id)).name, 'Coffee Shop');
- const k1 = byUrl('https://kettles.example/acme-2000');
- const k2 = byUrl('https://market.example/acme-2000');
+ const k1 = await byUrl('https://kettles.example/acme-2000');
+ const k2 = await byUrl('https://market.example/acme-2000');
assert.ok(k1 && k2 && k1.product_id && k1.product_id === k2.product_id, 'one product, two offers (tracking parameter stripped)');
assert.ok(k1 && k2 && k1.product_id && k1.product_id === k2.product_id, 'one product, two offers (tracking parameter stripped)');
- assert.strictEqual(obsOf(k2.id)[0].price, null);
- assert.strictEqual(obsOf(k2.id)[0].availability, 'out_of_stock');
- const product = t.ctx.catalog.product(k1.product_id);
+ assert.strictEqual((await obsOf(k2.id))[0].price, null);
+ assert.strictEqual((await obsOf(k2.id))[0].availability, 'out_of_stock');
+ const product = await t.ctx.catalog.product(k1.product_id);
assert.strictEqual(product.brand, 'Acme', 'the brand the source stated');
assert.strictEqual(product.brand, 'Acme', 'the brand the source stated');
const p = (await t.get(`/p/${product.slug}.json`)).json();
const p = (await t.get(`/p/${product.slug}.json`)).json();
assert.strictEqual(p.offers.length, 2);
assert.strictEqual(p.offers.length, 2);
@@ -49,7 +49,7 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -49,7 +49,7 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
});
});
await check('imported text is noindex until a person reviews it; review is recorded and re-indexes', async () => {
await check('imported text is noindex until a person reviews it; review is recorded and re-indexes', async () => {
- const esp = byUrl('https://coffee.example/espresso');
+ const esp = await byUrl('https://coffee.example/espresso');
const page = await t.get(`/d/${esp.slug}`);
const page = await t.get(`/d/${esp.slug}`);
assert.strictEqual(robotsOf(page.text), 'noindex, follow');
assert.strictEqual(robotsOf(page.text), 'noindex, follow');
assert.match(page.text, /imported from a source and has not been reviewed/);
assert.match(page.text, /imported from a source and has not been reviewed/);
@@ -61,20 +61,20 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -61,20 +61,20 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const after = await t.offerJson(esp.slug);
const after = await t.offerJson(esp.slug);
assert.strictEqual(after.review_state, 'reviewed');
assert.strictEqual(after.review_state, 'reviewed');
assert.strictEqual(after.indexability.indexable, true);
assert.strictEqual(after.indexability.indexable, true);
- const doc = t.events('deals.index_document.upserted').filter((e) => e.payload.id === esp.id).pop().payload;
+ const doc = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === esp.id).pop().payload;
assert.strictEqual(doc.indexability.decision, 'index');
assert.strictEqual(doc.indexability.decision, 'index');
assert.strictEqual(doc.authorship, 'imported');
assert.strictEqual(doc.authorship, 'imported');
assert.deepStrictEqual(doc.provenance.map((x) => [x.service, x.type, x.id]), [['sources', 'item', 'itm_01K5AAAAAAAAAAAAAAAAAAAAA1']]);
assert.deepStrictEqual(doc.provenance.map((x) => [x.service, x.type, x.id]), [['sources', 'item', 'itm_01K5AAAAAAAAAAAAAAAAAAAAA1']]);
});
});
await check('replaying the feed changes nothing (same revision, same retrieval)', async () => {
await check('replaying the feed changes nothing (same revision, same retrieval)', async () => {
- const count = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get().n;
- const events = t.events().length;
- t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();
+ const count = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get()).n;
+ const events = (await t.events()).length;
+ await t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();
const s = await t.ctx.importer.pull();
const s = await t.ctx.importer.pull();
assert.deepStrictEqual(s.outcomes, { unchanged: 4 });
assert.deepStrictEqual(s.outcomes, { unchanged: 4 });
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get().n, count);
- assert.strictEqual(t.events().length, events);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get()).n, count);
+ assert.strictEqual((await t.events()).length, events);
});
});
await check('a new revision is a new observation; the history keeps the old price with its time', async () => {
await check('a new revision is a new observation; the history keeps the old price with its time', async () => {
@@ -83,8 +83,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -83,8 +83,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
fields: { price: '229.00', currency: 'EUR', availability: 'LimitedAvailability', seller: 'Coffee Shop' } }));
fields: { price: '229.00', currency: 'EUR', availability: 'LimitedAvailability', seller: 'Coffee Shop' } }));
const s = await t.ctx.importer.pull();
const s = await t.ctx.importer.pull();
assert.deepStrictEqual(s.outcomes, { updated: 1 });
assert.deepStrictEqual(s.outcomes, { updated: 1 });
- const esp = byUrl('https://coffee.example/espresso');
- assert.deepStrictEqual(obsOf(esp.id).map((o) => [o.price, o.source_revision]), [['249.00', 1], ['229.00', 2]]);
+ const esp = await byUrl('https://coffee.example/espresso');
+ assert.deepStrictEqual((await obsOf(esp.id)).map((o) => [o.price, o.source_revision]), [['249.00', 1], ['229.00', 2]]);
assert.strictEqual((await t.offerJson(esp.slug)).latest_observation.price, '229.00');
assert.strictEqual((await t.offerJson(esp.slug)).latest_observation.price, '229.00');
});
});
@@ -95,7 +95,7 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -95,7 +95,7 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA4', sourceKey: 'other-feed', kind: 'article', title: 'Espresso deal', url: 'http://www.coffee.example/espresso', retrievedAt: t.clock.now() }));
t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA4', sourceKey: 'other-feed', kind: 'article', title: 'Espresso deal', url: 'http://www.coffee.example/espresso', retrievedAt: t.clock.now() }));
const s = await t.ctx.importer.pull();
const s = await t.ctx.importer.pull();
assert.deepStrictEqual(s.outcomes, { attached: 1 });
assert.deepStrictEqual(s.outcomes, { attached: 1 });
- const esp = byUrl('https://coffee.example/espresso');
+ const esp = await byUrl('https://coffee.example/espresso');
const json = await t.offerJson(esp.slug);
const json = await t.offerJson(esp.slug);
assert.deepStrictEqual(json.sources.map((x) => x.key).sort(), ['other-feed', 'shop-jsonld']);
assert.deepStrictEqual(json.sources.map((x) => x.key).sort(), ['other-feed', 'shop-jsonld']);
});
});
@@ -106,8 +106,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -106,8 +106,8 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
it.provenance = { ...it.provenance, retrieved_at: new Date(t.clock.now() - HOUR).toISOString() };
it.provenance = { ...it.provenance, retrieved_at: new Date(t.clock.now() - HOUR).toISOString() };
const r = await t.ctx.importer.refresh();
const r = await t.ctx.importer.refresh();
assert.ok(r.outcomes.refreshed >= 1, JSON.stringify(r));
assert.ok(r.outcomes.refreshed >= 1, JSON.stringify(r));
- const garden = byUrl('https://news.example/garden');
- const obs = obsOf(garden.id);
+ const garden = await byUrl('https://news.example/garden');
+ const obs = await obsOf(garden.id);
assert.strictEqual(obs.length, 2);
assert.strictEqual(obs.length, 2);
assert.strictEqual(obs[1].origin, 'import_refresh');
assert.strictEqual(obs[1].origin, 'import_refresh');
assert.strictEqual(obs[1].observed_at, t.clock.now() - HOUR);
assert.strictEqual(obs[1].observed_at, t.clock.now() - HOUR);
@@ -117,11 +117,11 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -117,11 +117,11 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
t.sources.put({ ...t.sources.items.find((i) => i.id === 'itm_01K5AAAAAAAAAAAAAAAAAAAAA3'), removed: { at: new Date(t.clock.now()).toISOString(), reason: 'licence withdrawn' } });
t.sources.put({ ...t.sources.items.find((i) => i.id === 'itm_01K5AAAAAAAAAAAAAAAAAAAAA3'), removed: { at: new Date(t.clock.now()).toISOString(), reason: 'licence withdrawn' } });
const s = await t.ctx.importer.pull();
const s = await t.ctx.importer.pull();
assert.deepStrictEqual(s.outcomes, { removed: 1 });
assert.deepStrictEqual(s.outcomes, { removed: 1 });
- const garden = byUrl('https://news.example/garden');
+ const garden = await byUrl('https://news.example/garden');
assert.strictEqual(garden.status, 'disabled');
assert.strictEqual(garden.status, 'disabled');
assert.match(garden.disabled_reason, /licence withdrawn/);
assert.match(garden.disabled_reason, /licence withdrawn/);
assert.strictEqual((await t.get(`/d/${garden.slug}`)).status, 410);
assert.strictEqual((await t.get(`/d/${garden.slug}`)).status, 410);
- assert.ok(t.events('deals.index_document.deleted').some((e) => e.payload.id === garden.id));
+ assert.ok((await t.events('deals.index_document.deleted')).some((e) => e.payload.id === garden.id));
assert.ok(!(await t.get('/feed.xml')).text.includes(garden.slug));
assert.ok(!(await t.get('/feed.xml')).text.includes(garden.slug));
});
});
@@ -141,12 +141,12 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -141,12 +141,12 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
});
});
await check('Sources down: the pull fails honestly (readiness says so), keeps its cursor, invents nothing', async () => {
await check('Sources down: the pull fails honestly (readiness says so), keeps its cursor, invents nothing', async () => {
- const cursor = t.ctx.importer.cursor();
- const offers = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n;
+ const cursor = await t.ctx.importer.cursor();
+ const offers = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get()).n;
t.sources.setDown(true);
t.sources.setDown(true);
await t.ctx.importer.pull();
await t.ctx.importer.pull();
- assert.strictEqual(t.ctx.importer.cursor(), cursor);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n, offers);
+ assert.strictEqual(await t.ctx.importer.cursor(), cursor);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get()).n, offers);
const ready = (await t.get('/api/ready')).json();
const ready = (await t.get('/api/ready')).json();
assert.strictEqual(ready.checks.sources_import.status, 'fail');
assert.strictEqual(ready.checks.sources_import.status, 'fail');
assert.match(ready.checks.sources_import.reason || JSON.stringify(ready.checks.sources_import), /Sources answered 503/);
assert.match(ready.checks.sources_import.reason || JSON.stringify(ready.checks.sources_import), /Sources answered 503/);
@@ -31,7 +31,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -31,7 +31,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
await check('only moderators merge', async () => {
await check('only moderators merge', async () => {
const r = await t.get(`/mod/offers/${B.slug}/merge`, { as: eve, form: { into: A.slug } });
const r = await t.get(`/mod/offers/${B.slug}/merge`, { as: eve, form: { into: A.slug } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- assert.strictEqual(t.ctx.reads.get(B.id).merged_into, null);
+ assert.strictEqual((await t.ctx.reads.get(B.id)).merged_into, null);
});
});
await check('merge B into A: the voter who voted on both counts once, every observation and source is kept', async () => {
await check('merge B into A: the voter who voted on both counts once, every observation and source is kept', async () => {
@@ -44,7 +44,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -44,7 +44,7 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
assert.strictEqual(a.latest_observation.price, '389', 'the latest observation of the group');
assert.strictEqual(a.latest_observation.price, '389', 'the latest observation of the group');
assert.strictEqual(a.sources.length, 3);
assert.strictEqual(a.sources.length, 3);
assert.deepStrictEqual(a.merged_from.map((m) => m.slug), [B.slug]);
assert.deepStrictEqual(a.merged_from.map((m) => m.slug), [B.slug]);
- const rows = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n;
+ const rows = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get()).n;
assert.strictEqual(rows, 4, 'no vote row was deleted, moved or copied');
assert.strictEqual(rows, 4, 'no vote row was deleted, moved or copied');
});
});
@@ -54,15 +54,15 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -54,15 +54,15 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
assert.strictEqual(page.headers.get('location'), `/d/${A.slug}`);
assert.strictEqual(page.headers.get('location'), `/d/${A.slug}`);
const json = await t.get(`/d/${B.slug}.json`);
const json = await t.get(`/d/${B.slug}.json`);
assert.strictEqual(json.headers.get('location'), `/d/${A.slug}.json`);
assert.strictEqual(json.headers.get('location'), `/d/${A.slug}.json`);
- const tomb = t.events('deals.index_document.deleted').filter((e) => e.payload.id === B.id);
+ const tomb = (await t.events('deals.index_document.deleted')).filter((e) => e.payload.id === B.id);
assert.strictEqual(tomb.length, 1);
assert.strictEqual(tomb.length, 1);
- const upd = t.events('deals.offer.updated').filter((e) => e.payload.merged_offer_id === B.id);
+ const upd = (await t.events('deals.offer.updated')).filter((e) => e.payload.merged_offer_id === B.id);
assert.strictEqual(upd.length, 1);
assert.strictEqual(upd.length, 1);
assert.strictEqual(upd[0].subject.id, A.id);
assert.strictEqual(upd[0].subject.id, A.id);
});
});
await check('the merge is audited with the tallies before and after', async () => {
await check('the merge is audited with the tallies before and after', async () => {
- const log = t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'merge'").all();
+ const log = await t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'merge'").all();
assert.strictEqual(log.length, 1);
assert.strictEqual(log.length, 1);
assert.strictEqual(log[0].offer_id, B.id);
assert.strictEqual(log[0].offer_id, B.id);
assert.strictEqual(log[0].target_id, A.id);
assert.strictEqual(log[0].target_id, A.id);
@@ -76,13 +76,13 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -76,13 +76,13 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
await check('a vote cast through the merged listing lands on the canonical offer; changing a vote counts once', async () => {
await check('a vote cast through the merged listing lands on the canonical offer; changing a vote counts once', async () => {
t.clock.advance(60 * 1000);
t.clock.advance(60 * 1000);
assert.strictEqual((await vote(v4, B.slug, 'up')).status, 303);
assert.strictEqual((await vote(v4, B.slug, 'up')).status, 303);
- const row = t.ctx.store.db.prepare('SELECT offer_id FROM deal_votes WHERE subject = ?').get(v4.subject);
+ const row = await t.ctx.store.db.prepare('SELECT offer_id FROM deal_votes WHERE subject = ?').get(v4.subject);
assert.strictEqual(row.offer_id, A.id);
assert.strictEqual(row.offer_id, A.id);
t.clock.advance(60 * 1000);
t.clock.advance(60 * 1000);
await vote(v1, A.slug, 'down');
await vote(v1, A.slug, 'down');
const a = await t.offerJson(A.slug);
const a = await t.offerJson(A.slug);
assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 2], 'v2, v4 up; v1 (now down, once), v3 down');
assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 2], 'v2, v4 up; v1 (now down, once), v3 down');
- assert.strictEqual(t.ctx.votes.mine(v1.subject, t.ctx.reads.get(A.id)), -1);
+ assert.strictEqual(await t.ctx.votes.mine(v1.subject, await t.ctx.reads.get(A.id)), -1);
});
});
await check('merging the canonical offer into its own duplicate is refused (no cycles)', async () => {
await check('merging the canonical offer into its own duplicate is refused (no cycles)', async () => {
@@ -103,14 +103,14 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
@@ -103,14 +103,14 @@ const { boot, check, done, HOUR } = require('./helpers/boot');
assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 1], 'A: v2, v4 up, v1 down (cast on A)');
assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 1], 'A: v2, v4 up, v1 down (cast on A)');
assert.strictEqual(a.observations.length, 1);
assert.strictEqual(a.observations.length, 1);
assert.strictEqual((await t.get(`/d/${B.slug}`)).status, 200);
assert.strictEqual((await t.get(`/d/${B.slug}`)).status, 200);
- const log = t.ctx.store.db.prepare("SELECT action FROM moderation_log WHERE action IN ('merge','unmerge') ORDER BY id").all().map((x) => x.action);
+ const log = (await t.ctx.store.db.prepare("SELECT action FROM moderation_log WHERE action IN ('merge','unmerge') ORDER BY id").all()).map((x) => x.action);
assert.deepStrictEqual(log, ['merge', 'unmerge']);
assert.deepStrictEqual(log, ['merge', 'unmerge']);
- const up = t.events('deals.index_document.upserted').filter((e) => e.payload.id === B.id).pop();
+ const up = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === B.id).pop();
assert.ok(up, 'B is back in Search');
assert.ok(up, 'B is back in Search');
});
});
await check('hotness after merge and unmerge is computed from the group (snapshots record the reason)', async () => {
await check('hotness after merge and unmerge is computed from the group (snapshots record the reason)', async () => {
- const reasons = t.ctx.store.db.prepare('SELECT reason FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id').all(A.id).map((x) => x.reason);
+ const reasons = (await t.ctx.store.db.prepare('SELECT reason FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id').all(A.id)).map((x) => x.reason);
assert.ok(reasons.includes('merge') && reasons.includes('unmerge'));
assert.ok(reasons.includes('merge') && reasons.includes('unmerge'));
t.clock.advance(HOUR);
t.clock.advance(HOUR);
});
});
@@ -34,13 +34,13 @@ const { sourceItem } = require('./helpers/mocks');
@@ -34,13 +34,13 @@ const { sourceItem } = require('./helpers/mocks');
const r = await t.get('/submit', { as: alice, form: { url: 'https://store.example/x', title: 'Currency test', price: '10' } });
const r = await t.get('/submit', { as: alice, form: { url: 'https://store.example/x', title: 'Currency test', price: '10' } });
assert.strictEqual(r.status, 422);
assert.strictEqual(r.status, 422);
assert.match(r.text, /needs its currency/);
assert.match(r.text, /needs its currency/);
- assert.strictEqual(t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE url = 'https://store.example/x'").get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE url = 'https://store.example/x'").get()).n, 0);
const bad = await t.get('/submit', { as: alice, form: { url: 'https://store.example/y', title: 'Symbol test', price: '$10', currency: 'USD' } });
const bad = await t.get('/submit', { as: alice, form: { url: 'https://store.example/y', title: 'Symbol test', price: '$10', currency: 'USD' } });
assert.strictEqual(bad.status, 422);
assert.strictEqual(bad.status, 422);
});
});
await check('the price comparison ranks fresh stated prices first; unknown and stale never sort as cheap', async () => {
await check('the price comparison ranks fresh stated prices first; unknown and stale never sort as cheap', async () => {
- const product = t.ctx.catalog.product(noPrice.product_id);
+ const product = await t.ctx.catalog.product(noPrice.product_id);
// An older, cheaper offer that goes stale, then a fresh one with a stated price.
// An older, cheaper offer that goes stale, then a fresh one with a stated price.
const stale = await t.submit(alice, { url: 'https://cheap.example/lamp', title: 'Desk lamp at Cheap', price: '5.00', currency: 'USD', product_name: 'Acme desk lamp' });
const stale = await t.submit(alice, { url: 'https://cheap.example/lamp', title: 'Desk lamp at Cheap', price: '5.00', currency: 'USD', product_name: 'Acme desk lamp' });
assert.strictEqual(stale.product_id, product.id, 'product resolved by its name alias');
assert.strictEqual(stale.product_id, product.id, 'product resolved by its name alias');
@@ -66,7 +66,7 @@ const { sourceItem } = require('./helpers/mocks');
@@ -66,7 +66,7 @@ const { sourceItem } = require('./helpers/mocks');
await check('a price in an imported headline is not parsed into a price', async () => {
await check('a price in an imported headline is not parsed into a price', async () => {
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ2', kind: 'article', title: 'Acme blender for $19 + free shipping', url: 'https://deals.example/blender', retrievedAt: t.clock.now() }));
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ2', kind: 'article', title: 'Acme blender for $19 + free shipping', url: 'https://deals.example/blender', retrievedAt: t.clock.now() }));
await t.ctx.importer.pull();
await t.ctx.importer.pull();
- const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/blender'").get();
+ const o = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/blender'").get();
const json = await t.offerJson(o.slug);
const json = await t.offerJson(o.slug);
assert.strictEqual(json.latest_observation.price, null);
assert.strictEqual(json.latest_observation.price, null);
assert.strictEqual(json.latest_observation.currency, null);
assert.strictEqual(json.latest_observation.currency, null);
@@ -75,7 +75,7 @@ const { sourceItem } = require('./helpers/mocks');
@@ -75,7 +75,7 @@ const { sourceItem } = require('./helpers/mocks');
await check('a malformed imported price stays null (never repaired)', async () => {
await check('a malformed imported price stays null (never repaired)', async () => {
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ3', title: 'Weird price', url: 'https://deals.example/weird', fields: { price: 'call us', currency: 'USD', availability: 'SomethingElse' }, retrievedAt: t.clock.now() }));
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ3', title: 'Weird price', url: 'https://deals.example/weird', fields: { price: 'call us', currency: 'USD', availability: 'SomethingElse' }, retrievedAt: t.clock.now() }));
await t.ctx.importer.pull();
await t.ctx.importer.pull();
- const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/weird'").get();
+ const o = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/weird'").get();
const json = await t.offerJson(o.slug);
const json = await t.offerJson(o.slug);
assert.strictEqual(json.latest_observation.price, null);
assert.strictEqual(json.latest_observation.price, null);
assert.strictEqual(json.latest_observation.availability, null, 'an unknown availability term is not guessed');
assert.strictEqual(json.latest_observation.availability, null, 'an unknown availability term is not guessed');
@@ -85,7 +85,7 @@ const { sourceItem } = require('./helpers/mocks');
@@ -85,7 +85,7 @@ const { sourceItem } = require('./helpers/mocks');
const feed = await t.get('/feed.json');
const feed = await t.get('/feed.json');
const item = feed.json().items.find((i) => i.url.endsWith(noPrice.slug));
const item = feed.json().items.find((i) => i.url.endsWith(noPrice.slug));
assert.match(item.summary, /Price not stated/);
assert.match(item.summary, /Price not stated/);
- const doc = t.events('deals.index_document.upserted').filter((e) => e.payload.id === noPrice.id).pop().payload;
+ const doc = (await t.events('deals.index_document.upserted')).filter((e) => e.payload.id === noPrice.id).pop().payload;
assert.match(doc.body, /price not stated/);
assert.match(doc.body, /price not stated/);
assert.strictEqual(doc.facets.currency, undefined);
assert.strictEqual(doc.facets.currency, undefined);
});
});
@@ -12,8 +12,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -12,8 +12,8 @@ const { boot, check, done } = require('./helpers/boot');
const t = await boot();
const t = await boot();
const poster = t.network.addUser('poster');
const poster = t.network.addUser('poster');
const reporter = t.network.addUser('reporter');
const reporter = t.network.addUser('reporter');
- const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()
- .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'deals.moderation.action');
+ const moderation = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all())
+ .map((r) => r.envelope).filter((e) => e.event_type === 'deals.moderation.action');
const valid = (e) => {
const valid = (e) => {
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
const r = contracts.validate('deals.moderation.action@1', e.payload);
const r = contracts.validate('deals.moderation.action@1', e.payload);
@@ -28,14 +28,14 @@ const { boot, check, done } = require('./helpers/boot');
@@ -28,14 +28,14 @@ const { boot, check, done } = require('./helpers/boot');
const o = await t.submit(poster, { url: 'https://s.example/own', title: 'My own offer', price: '5', currency: 'USD' });
const o = await t.submit(poster, { url: 'https://s.example/own', title: 'My own offer', price: '5', currency: 'USD' });
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: poster, json: { title: 'My own offer, edited' } })).status, 200);
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: poster, json: { title: 'My own offer, edited' } })).status, 200);
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/expire`, { as: poster, json: {} })).status, 200);
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/expire`, { as: poster, json: {} })).status, 200);
- assert.strictEqual(moderation().length, 0);
+ assert.strictEqual((await moderation()).length, 0);
});
});
await check('a moderator disabling someone else\'s offer: exactly one valid event', async () => {
await check('a moderator disabling someone else\'s offer: exactly one valid event', async () => {
const o = await t.submit(poster, { url: 'https://s.example/spam', title: 'Spammy offer', price: '1', currency: 'USD' });
const o = await t.submit(poster, { url: 'https://s.example/spam', title: 'Spammy offer', price: '1', currency: 'USD' });
const r = await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Referral spam' } });
const r = await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Referral spam' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
valid(ev[0]);
valid(ev[0]);
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `offer:${o.id}` });
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `offer:${o.id}` });
@@ -44,14 +44,14 @@ const { boot, check, done } = require('./helpers/boot');
@@ -44,14 +44,14 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(ev[0].payload.reason, 'Referral spam');
assert.strictEqual(ev[0].payload.reason, 'Referral spam');
assert.deepStrictEqual(ev[0].payload.details, { previous: 'active' });
assert.deepStrictEqual(ev[0].payload.details, { previous: 'active' });
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Again' } })).status, 200);
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Again' } })).status, 200);
- assert.strictEqual(moderation().length, 1, 'already disabled: nothing changed, nothing reported');
+ assert.strictEqual((await moderation()).length, 1, 'already disabled: nothing changed, nothing reported');
});
});
await check('a moderator editing someone else\'s offer: one offer.edited naming the fields, never the text', async () => {
await check('a moderator editing someone else\'s offer: one offer.edited naming the fields, never the text', async () => {
const o = await t.submit(poster, { url: 'https://s.example/typo', title: 'Offer with a tpyo', price: '2', currency: 'USD' });
const o = await t.submit(poster, { url: 'https://s.example/typo', title: 'Offer with a tpyo', price: '2', currency: 'USD' });
- const n = moderation().length;
+ const n = (await moderation()).length;
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: t.mod, json: { title: 'Offer with a typo' } })).status, 200);
assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: t.mod, json: { title: 'Offer with a typo' } })).status, 200);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, n + 1);
assert.strictEqual(ev.length, n + 1);
valid(ev[n]);
valid(ev[n]);
assert.strictEqual(ev[n].payload.action, 'offer.edited');
assert.strictEqual(ev[n].payload.action, 'offer.edited');
@@ -62,11 +62,11 @@ const { boot, check, done } = require('./helpers/boot');
@@ -62,11 +62,11 @@ const { boot, check, done } = require('./helpers/boot');
await check('a moderator dismissing a person\'s report: one flag.dismissed with the reporter as owner', async () => {
await check('a moderator dismissing a person\'s report: one flag.dismissed with the reporter as owner', async () => {
const o = await t.submit(poster, { url: 'https://s.example/reported', title: 'Reported offer', price: '3', currency: 'USD' });
const o = await t.submit(poster, { url: 'https://s.example/reported', title: 'Reported offer', price: '3', currency: 'USD' });
assert.ok((await t.get(`/api/v1/offers/${o.id}/flags`, { as: reporter, json: { kind: 'expired', reason: 'Sold out' } })).status < 300);
assert.ok((await t.get(`/api/v1/offers/${o.id}/flags`, { as: reporter, json: { kind: 'expired', reason: 'Sold out' } })).status < 300);
- const f = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE offer_id = ?').get(o.id);
- const n = moderation().length;
+ const f = await t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE offer_id = ?').get(o.id);
+ const n = (await moderation()).length;
const r = await t.get(`/api/v1/flags/${f.id}/resolve`, { as: t.mod, json: { status: 'dismissed', resolution: 'Still in stock' } });
const r = await t.get(`/api/v1/flags/${f.id}/resolve`, { as: t.mod, json: { status: 'dismissed', resolution: 'Still in stock' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, n + 1);
assert.strictEqual(ev.length, n + 1);
valid(ev[n]);
valid(ev[n]);
assert.strictEqual(ev[n].payload.action, 'flag.dismissed');
assert.strictEqual(ev[n].payload.action, 'flag.dismissed');
@@ -12,7 +12,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -12,7 +12,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
let deal, timed;
let deal, timed;
await check('the site starts empty: no seed deals, an honest empty page that is noindex', async () => {
await check('the site starts empty: no seed deals, an honest empty page that is noindex', async () => {
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get()).n, 0);
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /No deals have been posted yet/);
assert.match(r.text, /No deals have been posted yet/);
@@ -63,7 +63,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -63,7 +63,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
assert.match(r.headers.get('cache-control'), /private, no-store/);
assert.match(r.headers.get('cache-control'), /private, no-store/);
const thread = [...t.community.threads.values()][0];
const thread = [...t.community.threads.values()][0];
assert.deepStrictEqual(thread.ref, { service: 'deals', type: 'offer', id: deal.id, label: 'Mini drone with camera' });
assert.deepStrictEqual(thread.ref, { service: 'deals', type: 'offer', id: deal.id, label: 'Mini drone with camera' });
- const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('deal_offer_discussion_refs')").all().map((x) => x.name).join(',');
+ const cols = (await t.ctx.store.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'deal_offer_discussion_refs' ORDER BY ordinal_position").all()).map((x) => x.name).join(',');
assert.doesNotMatch(cols, /message|body|text/);
assert.doesNotMatch(cols, /message|body|text/);
t.community.setDown(true);
t.community.setDown(true);
const down = await t.get(`/d/${deal.slug}`);
const down = await t.get(`/d/${deal.slug}`);
@@ -73,7 +73,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -73,7 +73,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
await check('product, store and search pages', async () => {
await check('product, store and search pages', async () => {
const p = await t.submit(alice, { url: 'https://gadgets.example/drone-pro', title: 'Drone Pro', price: '199', currency: 'USD', product_name: 'SkyCam Drone Pro' });
const p = await t.submit(alice, { url: 'https://gadgets.example/drone-pro', title: 'Drone Pro', price: '199', currency: 'USD', product_name: 'SkyCam Drone Pro' });
- const prod = t.ctx.catalog.product(p.product_id);
+ const prod = await t.ctx.catalog.product(p.product_id);
const pr = await t.get(`/p/${prod.slug}`);
const pr = await t.get(`/p/${prod.slug}`);
assert.strictEqual(pr.status, 200);
assert.strictEqual(pr.status, 200);
assert.match(pr.text, /Price comparison/);
assert.match(pr.text, /Price comparison/);
@@ -97,7 +97,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -97,7 +97,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
const page = await t.get(`/d/${deal.slug}`);
const page = await t.get(`/d/${deal.slug}`);
assert.match(page.text, /This deal expired/);
assert.match(page.text, /This deal expired/);
assert.strictEqual(robotsOf(page.text), 'noindex, follow');
assert.strictEqual(robotsOf(page.text), 'noindex, follow');
- const e = t.events('deals.offer.expired');
+ const e = await t.events('deals.offer.expired');
assert.strictEqual(e.length, 1);
assert.strictEqual(e.length, 1);
assert.strictEqual(e[0].payload.reason, 'submitter');
assert.strictEqual(e[0].payload.reason, 'submitter');
assert.ok(!(await t.get('/')).text.includes(deal.slug));
assert.ok(!(await t.get('/')).text.includes(deal.slug));
@@ -110,16 +110,16 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -110,16 +110,16 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
assert.match((await t.get(`/d/${open.slug}`)).text, /End date: not stated/);
assert.match((await t.get(`/d/${open.slug}`)).text, /End date: not stated/);
t.clock.advance(3 * HOUR);
t.clock.advance(3 * HOUR);
await t.ctx.worker.tick();
await t.ctx.worker.tick();
- assert.strictEqual(t.ctx.reads.get(timed.id).status, 'expired');
- assert.strictEqual(t.ctx.reads.get(timed.id).expired_reason, 'stated_expiry');
- assert.strictEqual(t.ctx.reads.get(open.id).status, 'active');
+ assert.strictEqual((await t.ctx.reads.get(timed.id)).status, 'expired');
+ assert.strictEqual((await t.ctx.reads.get(timed.id)).expired_reason, 'stated_expiry');
+ assert.strictEqual((await t.ctx.reads.get(open.id)).status, 'active');
t.clock.advance(30 * 24 * HOUR);
t.clock.advance(30 * 24 * HOUR);
await t.ctx.worker.tick();
await t.ctx.worker.tick();
- assert.strictEqual(t.ctx.reads.get(open.id).status, 'active', 'stale, but never assumed expired');
+ assert.strictEqual((await t.ctx.reads.get(open.id)).status, 'active', 'stale, but never assumed expired');
});
});
await check('moderators disable a deal: 410, out of feeds, sitemaps and Search; enable brings it back', async () => {
await check('moderators disable a deal: 410, out of feeds, sitemaps and Search; enable brings it back', async () => {
- const target = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Drone Pro'").get();
+ const target = await t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Drone Pro'").get();
await t.get(`/d/${target.slug}`);
await t.get(`/d/${target.slug}`);
const thread = [...t.community.threads.values()].find((x) => x.ref.id === target.id);
const thread = [...t.community.threads.values()].find((x) => x.ref.id === target.id);
assert.ok(thread, 'the page resolved its Community thread');
assert.ok(thread, 'the page resolved its Community thread');
@@ -136,13 +136,13 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -136,13 +136,13 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
}
}
assert.strictEqual((await t.get(`/api/v1/offers/${target.id}`, { as: t.mod })).status, 200, 'moderators still read it');
assert.strictEqual((await t.get(`/api/v1/offers/${target.id}`, { as: t.mod })).status, 200, 'moderators still read it');
assert.ok(!(await t.get('/feed.xml')).text.includes(target.slug));
assert.ok(!(await t.get('/feed.xml')).text.includes(target.slug));
- assert.ok(t.events('deals.index_document.deleted').some((e) => e.payload.id === target.id));
+ assert.ok((await t.events('deals.index_document.deleted')).some((e) => e.payload.id === target.id));
const en = await t.get(`/mod/offers/${target.slug}/enable`, { as: t.mod, form: {} });
const en = await t.get(`/mod/offers/${target.slug}/enable`, { as: t.mod, form: {} });
assert.strictEqual(en.status, 303);
assert.strictEqual(en.status, 303);
assert.strictEqual((await t.get(`/d/${target.slug}`)).status, 200);
assert.strictEqual((await t.get(`/d/${target.slug}`)).status, 200);
await new Promise((ok) => setTimeout(ok, 100));
await new Promise((ok) => setTimeout(ok, 100));
assert.strictEqual(thread.visibility, 'public');
assert.strictEqual(thread.visibility, 'public');
- const log = t.ctx.store.db.prepare('SELECT action FROM moderation_log WHERE offer_id = ? ORDER BY id').all(target.id).map((x) => x.action);
+ const log = (await t.ctx.store.db.prepare('SELECT action FROM moderation_log WHERE offer_id = ? ORDER BY id').all(target.id)).map((x) => x.action);
assert.deepStrictEqual(log, ['disable', 'enable']);
assert.deepStrictEqual(log, ['disable', 'enable']);
});
});
@@ -170,7 +170,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -170,7 +170,7 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
assert.match(idx.text, /sitemaps\/offers.xml/);
assert.match(idx.text, /sitemaps\/offers.xml/);
assert.match(idx.text, /sitemaps\/products.xml/);
assert.match(idx.text, /sitemaps\/products.xml/);
const offers = await t.get('/sitemaps/offers.xml');
const offers = await t.get('/sitemaps/offers.xml');
- const fresh = t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get().slug;
+ const fresh = (await t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get()).slug;
assert.ok(offers.text.includes(fresh));
assert.ok(offers.text.includes(fresh));
assert.ok(!offers.text.includes(timed.slug), 'expired');
assert.ok(!offers.text.includes(timed.slug), 'expired');
assert.ok(!offers.text.includes(deal.slug), 'expired');
assert.ok(!offers.text.includes(deal.slug), 'expired');
@@ -11,7 +11,7 @@ const { sourceItem } = require('./helpers/mocks');
@@ -11,7 +11,7 @@ const { sourceItem } = require('./helpers/mocks');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const [watcher, poster, other] = ['watcher', 'poster', 'other'].map((n) => t.network.addUser(n));
const [watcher, poster, other] = ['watcher', 'poster', 'other'].map((n) => t.network.addUser(n));
- const matched = (watchId) => t.events('deals.watch.matched').filter((e) => !watchId || e.payload.watch_id === watchId);
+ const matched = async (watchId) => (await t.events('deals.watch.matched')).filter((e) => !watchId || e.payload.watch_id === watchId);
const w = {};
const w = {};
let offer;
let offer;
@@ -24,7 +24,7 @@ const { sourceItem } = require('./helpers/mocks');
@@ -24,7 +24,7 @@ const { sourceItem } = require('./helpers/mocks');
const r = await t.get('/watches', { as: watcher, form });
const r = await t.get('/watches', { as: watcher, form });
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
}
}
- const list = t.ctx.watches.list(watcher.subject);
+ const list = await t.ctx.watches.list(watcher.subject);
for (const x of list) w[x.kind === 'price_below' ? 'price' : x.kind === 'search' ? 'saved' : 'keyword'] = x.id;
for (const x of list) w[x.kind === 'price_below' ? 'price' : x.kind === 'search' ? 'saved' : 'keyword'] = x.id;
assert.strictEqual(list.length, 3);
assert.strictEqual(list.length, 3);
const page = await t.get('/watches', { as: watcher });
const page = await t.get('/watches', { as: watcher });
@@ -36,10 +36,10 @@ const { sourceItem } = require('./helpers/mocks');
@@ -36,10 +36,10 @@ const { sourceItem } = require('./helpers/mocks');
await check('a matching new offer notifies the keyword watch once; the price watch stays quiet above its limit', async () => {
await check('a matching new offer notifies the keyword watch once; the price watch stays quiet above its limit', async () => {
offer = await t.submit(poster, { url: 'https://audio.example/nc700', title: 'Noise cancelling headphones NC700', price: '49.99', currency: 'USD' });
offer = await t.submit(poster, { url: 'https://audio.example/nc700', title: 'Noise cancelling headphones NC700', price: '49.99', currency: 'USD' });
- assert.strictEqual(matched(w.keyword).length, 1);
- assert.strictEqual(matched(w.price).length, 0);
- assert.strictEqual(matched(w.saved).length, 0);
- const e = matched(w.keyword)[0];
+ assert.strictEqual((await matched(w.keyword)).length, 1);
+ assert.strictEqual((await matched(w.price)).length, 0);
+ assert.strictEqual((await matched(w.saved)).length, 0);
+ const e = (await matched(w.keyword))[0];
assert.strictEqual(e.payload.recipient, watcher.subject);
assert.strictEqual(e.payload.recipient, watcher.subject);
assert.strictEqual(e.visibility, 'internal');
assert.strictEqual(e.visibility, 'internal');
assert.strictEqual(e.payload.observation.price, '49.99');
assert.strictEqual(e.payload.observation.price, '49.99');
@@ -49,66 +49,66 @@ const { sourceItem } = require('./helpers/mocks');
@@ -49,66 +49,66 @@ const { sourceItem } = require('./helpers/mocks');
await check('a lower observation notifies the price watch; the keyword watch does not repeat for the same offer', async () => {
await check('a lower observation notifies the price watch; the keyword watch does not repeat for the same offer', async () => {
await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35', currency: 'USD' } });
await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35', currency: 'USD' } });
- assert.strictEqual(matched(w.price).length, 1);
- assert.strictEqual(matched(w.keyword).length, 1);
+ assert.strictEqual((await matched(w.price)).length, 1);
+ assert.strictEqual((await matched(w.keyword)).length, 1);
});
});
await check('re-running the matcher on the same observation emits nothing (one notification per observation)', async () => {
await check('re-running the matcher on the same observation emits nothing (one notification per observation)', async () => {
- const obs = t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at DESC, rowid DESC LIMIT 1').get(offer.id);
- const before = matched().length;
- for (let i = 0; i < 3; i++) t.ctx.store.tx(() => t.ctx.watches.onObservation(obs));
- assert.strictEqual(matched().length, before);
- const rows = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE observation_id = ?').get(obs.id).n;
+ const obs = await t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at DESC, seq DESC LIMIT 1').get(offer.id);
+ const before = (await matched()).length;
+ for (let i = 0; i < 3; i++) await t.ctx.store.tx(async () => await t.ctx.watches.onObservation(obs));
+ assert.strictEqual((await matched()).length, before);
+ const rows = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE observation_id = ?').get(obs.id)).n;
assert.strictEqual(rows, 1);
assert.strictEqual(rows, 1);
// The primary key is the guarantee, not just the code path.
// The primary key is the guarantee, not just the code path.
- const dup = t.ctx.store.db.prepare('INSERT OR IGNORE INTO watch_notifications (watch_id, observation_id, offer_id, created_at) VALUES (?, ?, ?, ?)').run(w.price, obs.id, offer.id, 0);
+ const dup = await t.ctx.store.db.prepare('INSERT INTO watch_notifications (watch_id, observation_id, offer_id, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING').run(w.price, obs.id, offer.id, 0);
assert.strictEqual(dup.changes, 0);
assert.strictEqual(dup.changes, 0);
});
});
await check('the same price again does not re-notify; a lower one does', async () => {
await check('the same price again does not re-notify; a lower one does', async () => {
t.clock.advance(HOUR);
t.clock.advance(HOUR);
await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35.00', currency: 'USD' } });
await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35.00', currency: 'USD' } });
- assert.strictEqual(matched(w.price).length, 1);
+ assert.strictEqual((await matched(w.price)).length, 1);
await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '29.90', currency: 'USD' } });
await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '29.90', currency: 'USD' } });
- assert.strictEqual(matched(w.price).length, 2);
+ assert.strictEqual((await matched(w.price)).length, 2);
});
});
await check('an unknown price, another currency, or the watcher\'s own report never match a price watch', async () => {
await check('an unknown price, another currency, or the watcher\'s own report never match a price watch', async () => {
- const before = matched(w.price).length;
+ const before = (await matched(w.price)).length;
const o2 = await t.submit(poster, { url: 'https://audio.example/cheap', title: 'Cheap headphones' });
const o2 = await t.submit(poster, { url: 'https://audio.example/cheap', title: 'Cheap headphones' });
await t.get(`/d/${o2.slug}/observe`, { as: other, form: { availability: 'in_stock' } });
await t.get(`/d/${o2.slug}/observe`, { as: other, form: { availability: 'in_stock' } });
await t.get(`/d/${o2.slug}/observe`, { as: other, form: { price: '10', currency: 'EUR' } });
await t.get(`/d/${o2.slug}/observe`, { as: other, form: { price: '10', currency: 'EUR' } });
await t.get(`/d/${o2.slug}/observe`, { as: watcher, form: { price: '9', currency: 'USD' } });
await t.get(`/d/${o2.slug}/observe`, { as: watcher, form: { price: '9', currency: 'USD' } });
- assert.strictEqual(matched(w.price).length, before);
+ assert.strictEqual((await matched(w.price)).length, before);
});
});
await check('an imported observation replayed from Sources notifies once', async () => {
await check('an imported observation replayed from Sources notifies once', async () => {
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW1', title: 'Wireless headphones deal', url: 'https://audio.example/wh', fields: { price: '19.99', currency: 'USD' }, retrievedAt: t.clock.now() }));
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW1', title: 'Wireless headphones deal', url: 'https://audio.example/wh', fields: { price: '19.99', currency: 'USD' }, retrievedAt: t.clock.now() }));
- const before = matched(w.price).length;
+ const before = (await matched(w.price)).length;
await t.ctx.importer.pull();
await t.ctx.importer.pull();
- assert.strictEqual(matched(w.price).length, before + 1);
+ assert.strictEqual((await matched(w.price)).length, before + 1);
// Replay the whole feed from the start: same items, same revision, same retrieval → nothing.
// Replay the whole feed from the start: same items, same revision, same retrieval → nothing.
- t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();
+ await t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();
await t.ctx.importer.pull();
await t.ctx.importer.pull();
- assert.strictEqual(matched(w.price).length, before + 1);
- const obsCount = t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_price_observations o JOIN deal_offers f ON f.id = o.offer_id WHERE f.url = 'https://audio.example/wh'").get().n;
+ assert.strictEqual((await matched(w.price)).length, before + 1);
+ const obsCount = (await t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_price_observations o JOIN deal_offers f ON f.id = o.offer_id WHERE f.url = 'https://audio.example/wh'").get()).n;
assert.strictEqual(obsCount, 1);
assert.strictEqual(obsCount, 1);
});
});
await check('an observation that is already stale when imported does not notify', async () => {
await check('an observation that is already stale when imported does not notify', async () => {
- const before = matched().length;
+ const before = (await matched()).length;
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW2', title: 'Headphones from last week', url: 'https://audio.example/old', fields: { price: '5', currency: 'USD' }, retrievedAt: t.clock.now() - 5 * 24 * HOUR }));
t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW2', title: 'Headphones from last week', url: 'https://audio.example/old', fields: { price: '5', currency: 'USD' }, retrievedAt: t.clock.now() - 5 * 24 * HOUR }));
await t.ctx.importer.pull();
await t.ctx.importer.pull();
- assert.strictEqual(matched().length, before);
+ assert.strictEqual((await matched()).length, before);
});
});
await check('a deleted watch stops; a saved search never notified', async () => {
await check('a deleted watch stops; a saved search never notified', async () => {
const r = await t.get(`/watches/${w.keyword}/delete`, { as: watcher, form: {} });
const r = await t.get(`/watches/${w.keyword}/delete`, { as: watcher, form: {} });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- const before = matched(w.keyword).length;
+ const before = (await matched(w.keyword)).length;
await t.submit(poster, { url: 'https://audio.example/nc800', title: 'Noise cancelling headphones NC800', price: '20', currency: 'USD' });
await t.submit(poster, { url: 'https://audio.example/nc800', title: 'Noise cancelling headphones NC800', price: '20', currency: 'USD' });
- assert.strictEqual(matched(w.keyword).length, before);
- assert.strictEqual(matched(w.saved).length, 0);
+ assert.strictEqual((await matched(w.keyword)).length, before);
+ assert.strictEqual((await matched(w.saved)).length, 0);
const cannot = await t.get(`/watches/${w.price}/delete`, { as: other, form: {} });
const cannot = await t.get(`/watches/${w.price}/delete`, { as: other, form: {} });
assert.strictEqual(cannot.status, 404, 'someone else\'s watch is not found');
assert.strictEqual(cannot.status, 404, 'someone else\'s watch is not found');
});
});
@@ -138,7 +138,7 @@ const { sourceItem } = require('./helpers/mocks');
@@ -138,7 +138,7 @@ const { sourceItem } = require('./helpers/mocks');
const sandbox = await t.get('/api/v1/watches', { as: app('app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app', { on_behalf_of: other.subject, env: 'sandbox' }) });
const sandbox = await t.get('/api/v1/watches', { as: app('app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app', { on_behalf_of: other.subject, env: 'sandbox' }) });
assert.strictEqual(sandbox.status, 401);
assert.strictEqual(sandbox.status, 401);
assert.strictEqual(sandbox.json().code, 'token.sandbox_refused');
assert.strictEqual(sandbox.json().code, 'token.sandbox_refused');
- assert.strictEqual(t.ctx.watches.list(watcher.subject).length, 2, 'the watcher\'s watches are untouched');
+ assert.strictEqual((await t.ctx.watches.list(watcher.subject)).length, 2, 'the watcher\'s watches are untouched');
});
});
await t.close();
await t.close();