openvibers/openvibe.deals Commits

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

OP OpenVibers committed 28 Sept 2026 · 69d3e47323 · 50 files, +1432 −976

- Schema: migrations/0001_initial.sql (openvibe-sdk tools/asyncify gen-migration: the SQLite schema converted, the
  Publishing discussion and index-hooks tables, the SDK inbox and outbox). deal_offers, deal_offer_sources,
  deal_votes and deal_price_observations gain seq (bigint identity) where the code ordered by rowid.
- server/db.js: openDb (DATABASE_URL through PgBouncer, migrations as the owner, PGlite in development) and
  createStore; store.tx is an ambient transaction.
- Every read and write is awaited (asyncify plus hand fixes: offer views in lists through Promise.all, the identity
  projection read in one query, computed moderation calls awaited). SQL: jsonb_array_elements_text for json_each,
  ON CONFLICT DO NOTHING for INSERT OR IGNORE, aggregates in HAVING (PostgreSQL has no aliases there), a typed
  COLLATE "C" seed for the merge-group recursive query. The Sources wake-up runs on the PostgreSQL inbox.
- Per-actor limits in Valkey; /api/ready reports the store that answered.
- scripts/migrate-to-postgres.js (runSqliteMigration); rehearsed on a copy of production: verified.
- Tests on createTestDb (PGlite; npm run test:pg through PgBouncer). Pins: openvibe-publishing v1.0.0,
  openvibe-contracts v0.76.0, openvibe-sdk v0.20.0. README and STATUS.json follow.

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

Changes

ddiff --git a/.env.example b/.env.example
iindex b125cf7..f1d05bc 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 7904984..06f896d 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
contracts-service: 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
ddiff --git a/README.md b/README.md
iindex 0a9299c..e32bb32 100644
---- a/README.md
++++ b/README.md
@@@ -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 th
active offer of the last 14 days at **one shared `t`**, so the hot list compares numbers computed at
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 |
|---|---|
@@@ -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_OAUTH
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`).
--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):
ddiff --git a/STATUS.json b/STATUS.json
iindex 69cf9dc..6d91863 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/deploy/systemd/openvibe-deals.service b/deploy/systemd/openvibe-deals.service
iindex 16863cf..630fde2 100644
---- a/deploy/systemd/openvibe-deals.service
++++ b/deploy/systemd/openvibe-deals.service
@@@ -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=simple
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..d424a3d
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 6ba51f8..a9f4ab3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,18 +15,34 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 326d01a..8eb248c 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100644
iindex 0000000..0e7bbae
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex d519eb3..beb62d0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) ─────────
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex 3cde192..c1a7ef9 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -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);
ddiff --git a/server/clients/community.js b/server/clients/community.js
iindex 54c408b..81cfacf 100644
---- a/server/clients/community.js
++++ b/server/clients/community.js
@@@ -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 });
ddiff --git a/server/clients/network.js b/server/clients/network.js
iindex 3398b49..408dad1 100644
---- a/server/clients/network.js
++++ b/server/clients/network.js
@@@ -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),
};
}
ddiff --git a/server/clients/sources.js b/server/clients/sources.js
iindex 16221e8..8cb4a4c 100644
---- a/server/clients/sources.js
++++ b/server/clients/sources.js
@@@ -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),
};
}
ddiff --git a/server/config.js b/server/config.js
iindex eed0a81..e4c8771 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/db.js b/server/db.js
iindex 31d1f59..f8f85ba 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/domain/catalog.js b/server/domain/catalog.js
iindex 647e903..f1bea9e 100644
---- a/server/domain/catalog.js
++++ b/server/domain/catalog.js
@@@ -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,
};
ddiff --git a/server/domain/flags.js b/server/domain/flags.js
iindex 7540432..d1a2c21 100644
---- a/server/domain/flags.js
++++ b/server/domain/flags.js
@@@ -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)`),
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, logAct
return {
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)),
};
}
ddiff --git a/server/domain/hotness.js b/server/domain/hotness.js
iindex d59bc2d..b291cc5 100644
---- a/server/domain/hotness.js
++++ b/server/domain/hotness.js
@@@ -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;
}
ddiff --git a/server/domain/importer.js b/server/domain/importer.js
iindex 581e5b1..b48d4f2 100644
---- a/server/domain/importer.js
++++ b/server/domain/importer.js
@@@ -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_price_observations o ON o.source_id = s.id
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 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, sourc
return [];
}
-- 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, sourc
source_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, sourc
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. */
async function pull() {
@@@ -191,18 +191,18 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
const 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, sourc
return {
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) }),
};
}
ddiff --git a/server/domain/indexing.js b/server/domain/indexing.js
iindex 2800df1..b90699f 100644
---- a/server/domain/indexing.js
++++ b/server/domain/indexing.js
@@@ -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 };
ddiff --git a/server/domain/limits.js b/server/domain/limits.js
iindex b165ebc..88fcb9a 100644
---- a/server/domain/limits.js
++++ b/server/domain/limits.js
@@@ -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 };
ddiff --git a/server/domain/listings.js b/server/domain/listings.js
iindex 53f4789..bf3f8fd 100644
---- a/server/domain/listings.js
++++ b/server/domain/listings.js
@@@ -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,
};
}
ddiff --git a/server/domain/offers.js b/server/domain/offers.js
iindex 3927419..f372e3f 100644
---- a/server/domain/offers.js
++++ b/server/domain/offers.js
@@@ -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 {
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex 0f5cc99..0aef193 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -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) {
ddiff --git a/server/domain/reads.js b/server/domain/reads.js
iindex 008629e..a257223 100644
---- a/server/domain/reads.js
++++ b/server/domain/reads.js
@@@ -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_at
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
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'),
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,
};
}
ddiff --git a/server/domain/votes.js b/server/domain/votes.js
iindex f591f93..4ed17fc 100644
---- a/server/domain/votes.js
++++ b/server/domain/votes.js
@@@ -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)
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 n
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
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 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, out
hotness: { 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,
};
ddiff --git a/server/domain/watches.js b/server/domain/watches.js
iindex 0b40713..3a5d34b 100644
---- a/server/domain/watches.js
++++ b/server/domain/watches.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 294b0e1..1336a6b 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -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 }),
};
}
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex d2f5878..edcfeed 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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, lo
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
actor,
now,
++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
++ // 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}`);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 3a87cc0..01029e7 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 }));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 4dcb538..83abe7f 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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;
}
ddiff --git a/server/http/internal.js b/server/http/internal.js
iindex 4d1517b..75ab80c 100644
---- a/server/http/internal.js
++++ b/server/http/internal.js
@@@ -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 });
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b7d7ede..683ef45 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 };
}
ddiff --git a/server/index.js b/server/index.js
iindex 03a3f60..0658fc3 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -7,11 +7,12 @@
*/
const { createApp } = require('./app');
--const { app, ctx } = createApp();
++(async () => {
++const { app, ctx } = await createApp();
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
-- console.log(`[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); });
ddiff --git a/server/observability.js b/server/observability.js
iindex a44cec8..89d8acb 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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, r
checks: [
{
name: 'db', required: true,
-- check: () => {
-- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
++ check: async () => {
++ // A real round trip that names the store (postgresql / pglite), and the charter tables present.
++ const r = await db.ready();
++ if (!r.ok) return r.error;
++ const names = new Set((await db.prepare('SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()').all()).map((x) => x.name));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
-- return missing.length ? `missing ${missing.join(', ')}` : true;
++ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
},
},
++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
name: 'network_jwks', required: false,
check: () => {
@@@ -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 } };
ddiff --git a/server/worker.js b/server/worker.js
iindex 3103509..6491d63 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -24,17 +24,17 @@ function createWorker({ config, store, offers, hotness, indexing, listings, impo
running = 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;
ddiff --git a/test/abuse.test.js b/test/abuse.test.js
iindex c4b2357..ef4552f 100644
---- a/test/abuse.test.js
++++ b/test/abuse.test.js
@@@ -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();
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 98b3ea8..eb76888 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -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');
});
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 2b21603..89d36ce 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -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) {
ddiff --git a/test/freshness.test.js b/test/freshness.test.js
iindex 7d8a56d..4475b75 100644
---- a/test/freshness.test.js
++++ b/test/freshness.test.js
@@@ -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');
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 26e68d2..b0356f3 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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;
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..d7c643d
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * 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 };
ddiff --git a/test/hotness.test.js b/test/hotness.test.js
iindex ea274e6..280d7db 100644
---- a/test/hotness.test.js
++++ b/test/hotness.test.js
@@@ -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');
ddiff --git a/test/import.test.js b/test/import.test.js
iindex 7583430..4982cb7 100644
---- a/test/import.test.js
++++ b/test/import.test.js
@@@ -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/);
ddiff --git a/test/merge.test.js b/test/merge.test.js
iindex 9a56195..cadce57 100644
---- a/test/merge.test.js
++++ b/test/merge.test.js
@@@ -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);
});
ddiff --git a/test/missing-price.test.js b/test/missing-price.test.js
iindex d1d9b7f..0fa16c8 100644
---- a/test/missing-price.test.js
++++ b/test/missing-price.test.js
@@@ -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);
});
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
iindex 9d6b413..3e80099 100644
---- a/test/moderation-events.test.js
++++ b/test/moderation-events.test.js
@@@ -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');
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 31a0f77..4243899 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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');
ddiff --git a/test/watches.test.js b/test/watches.test.js
iindex f92d706..6448461 100644
---- a/test/watches.test.js
++++ b/test/watches.test.js
@@@ -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();