Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
df1ef75
fix(importer): watch band size range or list before the band keys siz…
JOY Oct 28, 2026
6d0d72b
fix(web): apply category spec templates to compare, leaderboard, expo…
JOY Oct 28, 2026
aa77634
fix(importer): kept variant keeps its item and the left item is refre…
JOY Oct 28, 2026
ec2bce5
fix(web): api v1 filters and extension lookup follow category spec te…
JOY Oct 28, 2026
88f043d
fix(web): hide empty spec rows and groups on the product page (#1192)
JOY Oct 28, 2026
aa751fd
test(normalizer): pin the thinkpad 21kc008pvn title to unknown, not 5…
JOY Oct 28, 2026
94ca73f
fix(web): show unknown specs as empty instead of invented values (#1199)
JOY Oct 28, 2026
081e59b
fix(web): no half-translated spec values in vietnamese (#1201)
JOY Oct 28, 2026
166ef4b
fix(importer): tier 2 never links a storage-less title to a sized mas…
JOY Oct 28, 2026
57d8892
fix(normalizer): read apple generation words, chips and part numbers …
JOY Oct 28, 2026
f371861
feat(web): sale state for published models with no in-stock listing (…
JOY Oct 28, 2026
a215d0f
fix(normalizer): a supported app or service sets no brand or model (#…
JOY Oct 28, 2026
cb189f0
fix(importer): an unknown 5g is stored as null, not false (#1207)
JOY Oct 28, 2026
0366a88
fix(importer): ignore masterIdHint when the page states another stora…
JOY Oct 28, 2026
f85dc6d
feat(importer): item seeds, per-item specs and rating, inherited vari…
JOY Oct 28, 2026
a238a2c
fix(normalizer): pin the series 11 registry entry to its 46mm case (#…
JOY Oct 28, 2026
ecd4883
fix(web): ai review summary says it covers one store's latest analysi…
JOY Oct 28, 2026
e80fd3b
fix(web): four vietnamese copy defects on product, compare and deals …
JOY Oct 28, 2026
1ffc96c
fix(web): review cards label the rating weight and the trust estimate…
JOY Oct 28, 2026
82e911d
fix(normalizer): key garmin forerunner 265s apart from the 265 (#1227)
JOY Oct 28, 2026
25d8821
fix(importer,web): ai review summary says it covers one listing's bat…
JOY Oct 28, 2026
ead2285
fix(normalizer): a size that names the case wins over a band size bef…
JOY Oct 28, 2026
f1a46f4
fix(web): a 1v1 tie says tie and score gaps read as points (#1228)
JOY Oct 28, 2026
08d96c4
perf(web): cache merchant profiles without per-master reads (#1237)
JOY Oct 28, 2026
ae56f71
fix(web): privacy policy covers the extension; ingest limit copy is h…
JOY Oct 28, 2026
1fefafa
fix(extension): truthful opt-in label, store doc and narrower tiktok …
JOY Oct 28, 2026
b3b043b
feat(importer): make arena spec sync, review risk and peer floors ite…
JOY Oct 28, 2026
33eb709
fix(extension,web): tiktok pdp item ids; brand and category pages are…
JOY Oct 28, 2026
02d4453
fix(web): merchant profile follow-ups from the cold-render review (#1…
JOY Oct 28, 2026
ba87a00
fix(web): versus verdict follows the scoreboard's weights (#1238)
JOY Oct 28, 2026
6cb910e
fix(importer,web): scope llm review summaries and drop quoted scores …
JOY Oct 28, 2026
71de13f
fix(web): merchant pages state sold-out listings and missing reviews …
JOY Oct 28, 2026
08da8dc
fix(web): leaderboard reads in vietnamese and drops the stray 10.0 (#…
JOY Oct 28, 2026
7e1a3e1
docs(proposals): dos-me m1+m2 item migration proposal with replay tes…
JOY Oct 28, 2026
cc0ca90
fix(web): no review risk number on seller surfaces without reviews (#…
JOY Oct 28, 2026
40f42ff
feat(web): item data layer, catalogue slug resolution and 308s behind…
JOY Oct 28, 2026
71289c2
fix(normalizer): a word starting with s after forerunner 265 is no su…
JOY Oct 28, 2026
0e30eb9
fix(web): localize the product page seller line labels (#1270)
JOY Oct 28, 2026
1cf12a5
fix(normalizer): a gifted or bundled case size is not the watch's (#1…
JOY Oct 28, 2026
b28c4ee
docs(proposals): dos-me m0 seed re-key data step with replay test (#1…
JOY Oct 28, 2026
0fd5d96
feat(web): directory, facets, leaderboard, stats and sitemap per item…
JOY Oct 28, 2026
872743e
test(e2e): item-model e2e job with the m0 and m1+m2 proposals and ite…
JOY Oct 28, 2026
d9f4920
feat(web): item product page with size picker, unknown-size group and…
JOY Oct 28, 2026
018ad88
feat(api): item fields, group=item and an item-aware extension lookup…
JOY Oct 28, 2026
9f31963
fix(normalizer): keep vỏ-material and gift-only sizes, linear pipe ch…
JOY Oct 28, 2026
e658ee6
feat(web): compare, versus and watchlist per item (#1282)
JOY Oct 28, 2026
6bd98c4
fix(web): read the merchant risk split, label the column as combined …
JOY Oct 28, 2026
4542e2a
docs(proposals): dos-me item catalogue rpcs and m3/m4 with rollbacks …
JOY Oct 28, 2026
5d59ac4
fix(web): leaderboard ranks 100 items and links item pairs (#1286)
JOY Oct 28, 2026
54ac7be
feat(web): watchlist asks for a size when an entry is an item (#1284)
JOY Oct 28, 2026
0997ca7
fix(web): item pages under their own category, unknown-size history v…
JOY Oct 28, 2026
70bf46e
docs(proposals): item model go-live runbook for joy (#1288)
JOY Oct 28, 2026
f67509b
fix(web): item catalogue, versus and sitemap edge cases (#1289)
JOY Oct 28, 2026
7901172
fix(web): item versus links, filtered size chips and the read cap (#1…
JOY Oct 28, 2026
3388764
fix(web): restored compare tray links item pairs without a 308 (#1295)
JOY Oct 28, 2026
eb48aea
feat(web): load item size panels on demand for items with many sizes …
JOY Oct 28, 2026
4eca23d
fix(normalizer): a gift size never stands in for a product size the r…
JOY Oct 28, 2026
b1fd90d
fix(web): compare links with ?cat= open on their category (#1316)
JOY Oct 28, 2026
db049c4
fix(extension): price list, badge, empty and error states, labels (#1…
JOY Oct 28, 2026
cc531ea
fix(web): a post-login target that normalizes to //host goes home (#1…
JOY Oct 29, 2026
f39ad3b
fix(web): a vote flushes only its product's caches, and only when it …
JOY Oct 29, 2026
5b4ee2c
fix(normalizer): keep the size before năm <year>; dates and units are…
JOY Oct 29, 2026
87d2e3b
fix(web): reviews by product id serve published products only (#1314)
JOY Oct 29, 2026
01e7de5
fix(web): rate-limit inspect and lookup and cache their results (#1322)
JOY Oct 29, 2026
8c1cca0
test(e2e): print the item size-panel payload numbers to the ci log (#…
JOY Oct 29, 2026
dcfc88a
fix(web): public api errors answer a fixed message unless the error i…
JOY Oct 29, 2026
67938aa
feat(web): report-only content security policy with a per-request scr…
JOY Oct 29, 2026
e02bd31
feat(extension): vi, en and cs through chrome.i18n (#1325)
JOY Oct 29, 2026
40ddb30
test(web): lock the cache mode of the home page and the category page…
JOY Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ jobs:
- name: Replay rate migrations on PostgreSQL 17 (Docker)
run: node scripts/test-migration-replay.mjs

- name: Replay the DOS-Me Item migration proposal (Docker)
run: node scripts/test-item-migration-proposal.mjs

- name: Replay the DOS-Me M0 seed re-key proposal (Docker)
run: node scripts/test-m0-seed-rekey-proposal.mjs

- name: Replay the DOS-Me Item RPC and M3/M4 proposals (Docker)
run: node scripts/test-m3-m4-item-rpcs-proposal.mjs

- name: Check generated rate schema types match the migrations
run: pnpm check:db-types

Expand Down Expand Up @@ -147,3 +156,43 @@ jobs:
playwright-report/
test-results/e2e/
retention-days: 7

# RATE_ITEM_MODEL=nullable_variant on the DOS-Me M0 + M1+M2 proposals (DOS/Rate#1159):
# the Item routes, redirects and canonicals. The default E2E job above stays flag-off.
e2e-item-model:
name: E2E (Playwright, Item model)
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10.34.5

- name: Setup Node.js environment
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium

- name: Run Item-model browser E2E against a throwaway database
run: node scripts/e2e/run.mjs --item-model

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report-item-model
path: |
playwright-report-item-model/
test-results/e2e-item-model/
retention-days: 7
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Rate.code-workspace

# Playwright HTML report
playwright-report/
playwright-report-item-model/

# Secrets and local data that must never be committed (DOS/Rate#153):
# Windows Explorer copies (English "x - Copy.env", Vietnamese "x - Bản sao"),
Expand All @@ -135,6 +136,19 @@ playwright-report/
!supabase/migrations/*.sql
# Reviewed E2E fixture: synthetic catalog rows only, no credentials.
!apps/web/e2e/seed.sql
!apps/web/e2e/item-model/seed.sql
# Reviewed DOS-Me migration proposal (DOS/Rate#1150): DDL only, no data, no credentials.
!Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql
!Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql
!Docs/Proposals/2026-10-28-dos-me-m1-m2-items-verify.sql
!Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql
!Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-rollback.sql
!Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-verify.sql
!Docs/Proposals/2026-10-29-dos-me-item-rpcs.sql
!Docs/Proposals/2026-10-29-dos-me-item-rpcs-rollback.sql
!Docs/Proposals/2026-10-29-dos-me-m3-m4.sql
!Docs/Proposals/2026-10-29-dos-me-m3-m4-rollback.sql
!Docs/Proposals/2026-10-29-dos-me-m3-m4-verify.sql
* - Copy*
* - Bản sao*
*.dump
Expand Down
230 changes: 230 additions & 0 deletions Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
-- PROPOSAL, NOT APPLIED. Rollback of 2026-10-28-dos-me-m1-m2-items.sql (DOS/Rate#1150,
-- design section 7 "M1"/"M2" reversibility). Run with the writers paused (importer and Rate
-- Services workers stopped), in one transaction. Re-runnable.
--
-- Never TRUNCATE rate.items: it fails while the FKs exist, and TRUNCATE ... CASCADE would wipe
-- master_products, products and through them price history, reviews and alerts.
-- Data written only by this migration (Items, item_id, variant_key, variant_source) is lost;
-- nothing older depends on it. The two rating functions get their previous bodies back
-- (20261018153000 and 20261018120103), so the old readers keep working.

BEGIN;

-- 1. Triggers first, so the column drops below fire nothing.
DROP TRIGGER IF EXISTS trg_products_recheck_retired_variant ON rate.products;
DROP TRIGGER IF EXISTS trg_products_refresh_left ON rate.products;
DROP TRIGGER IF EXISTS trg_products_sync_item ON rate.products;
DROP TRIGGER IF EXISTS trg_master_products_move_listings ON rate.master_products;
DROP TRIGGER IF EXISTS trg_master_products_sync_item ON rate.master_products;
DROP TRIGGER IF EXISTS trg_rate_items_set_updated_at ON rate.items;

-- 2. Previous rating bodies (verbatim from the reference migrations).
CREATE OR REPLACE FUNCTION rate.refresh_master_product_rating(p_master_id UUID)
RETURNS void
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
AS $$
UPDATE rate.master_products m
SET user_rating_weighted = COALESCE(s.weighted, 0),
authentic_review_count = COALESCE(s.n, 0),
updated_at = now()
FROM (
SELECT
round(
(sum(r.rating_star * r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user'))
/ NULLIF(sum(r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user'), 0),
2
) AS weighted,
(count(*) FILTER (WHERE r.reviewer_type = 'user'))::INTEGER AS n
FROM rate.products p
JOIN rate.reviews r ON r.product_id = p.id
JOIN rate.review_flags f ON f.review_id = r.id
WHERE p.cluster_id = p_master_id
AND p.origin = 'crawl'
AND r.origin IN ('crawl', 'community')
-- Reviews excluded from the rating (unanalyzed community reviews,
-- flagged ones) never count, whatever their weight (20261014033000).
AND COALESCE(f.include_in_rating, true)
AND r.helpfulness_weight > 0
) s
WHERE m.id = p_master_id;
$$;

CREATE OR REPLACE FUNCTION rate.refresh_review_vote_counts(p_review_id UUID)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_up INTEGER;
v_down INTEGER;
v_rep INTEGER;
v_cat TEXT;
v_verdict TEXT;
v_inc BOOLEAN;
v_master UUID;
BEGIN
SELECT
count(*) FILTER (WHERE vote = 'up'),
count(*) FILTER (WHERE vote = 'down'),
count(*) FILTER (WHERE reported)
INTO v_up, v_down, v_rep
FROM rate.review_votes
WHERE review_id = p_review_id;

SELECT f.flag_category, f.verdict, COALESCE(f.include_in_rating, true)
INTO v_cat, v_verdict, v_inc
FROM rate.review_flags f
WHERE f.review_id = p_review_id;

-- Votes cannot lift a review that is excluded from the rating
-- (20261014033000, relied on by 20261017120000's unanalyzed reviews).
UPDATE rate.reviews
SET upvotes = v_up,
downvotes = v_down,
report_count = v_rep,
helpfulness_weight = CASE
WHEN NOT COALESCE(v_inc, true) THEN 0.00
ELSE rate.compute_review_weight(
v_up, v_down, v_rep,
COALESCE(v_cat, 'authentic'),
COALESCE(v_verdict, 'unanalyzed')
)
END
WHERE id = p_review_id;

SELECT p.cluster_id INTO v_master
FROM rate.reviews r
JOIN rate.products p ON p.id = r.product_id
WHERE r.id = p_review_id;

IF v_master IS NOT NULL THEN
PERFORM rate.refresh_master_product_rating(v_master);
END IF;
END;
$$;

CREATE OR REPLACE FUNCTION rate.refresh_master_market_stats(p_master_id UUID)
RETURNS void
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
AS $$
WITH priced AS (
SELECT
p.price_current AS price,
COALESCE(NULLIF(p.price_max, 0), p.price_current) AS price_top,
p.merchant_id,
(pg_catalog.row_number() OVER (ORDER BY p.price_current, p.id) - 1)::INTEGER AS k,
(pg_catalog.count(*) OVER ())::INTEGER AS m
FROM rate.products p
WHERE p.cluster_id = p_master_id
AND p.origin = 'crawl'
AND p.availability = 'in_stock'
AND p.price_current > 0
),
judged AS (
SELECT
c.price,
c.price_top,
c.merchant_id,
(c.m >= 3 AND c.price < 0.4 * r.price) AS implausible
FROM priced c
LEFT JOIN priced r
ON r.k = CASE WHEN (c.m - 2) / 2 < c.k THEN (c.m - 2) / 2 ELSE (c.m - 2) / 2 + 1 END
),
s AS (
SELECT
MIN(j.price) FILTER (WHERE NOT j.implausible) AS min_price,
MAX(j.price_top) AS max_price,
COUNT(DISTINCT j.merchant_id)::INTEGER AS merchant_count
FROM judged j
)
UPDATE rate.master_products m
SET min_price = s.min_price,
max_price = s.max_price,
merchant_count = s.merchant_count,
updated_at = now()
FROM s
WHERE m.id = p_master_id
AND (m.min_price, m.max_price, m.merchant_count)
IS DISTINCT FROM (s.min_price, s.max_price, s.merchant_count);
$$;

-- 2b. Put back what retire_item_unknown_variant changed (it records it in `metadata`), then
-- recompute every variant's rating and prices with the old bodies, so no pooled Item rating
-- stays mirrored on a variant. Listings that never had a variant (written with
-- RATE_ITEM_MODEL=nullable_variant) keep cluster_id NULL: the old schema cannot say which
-- Item they belong to. Count them first with
-- SELECT count(*) FROM rate.products WHERE cluster_id IS NULL AND item_id IS NOT NULL
-- AND NOT (metadata ? 'retired_unknown_variant');
UPDATE rate.master_products mp
SET status = mp.metadata ->> 'status_before_retire',
metadata = mp.metadata - 'status_before_retire' - 'retired_by'
WHERE mp.metadata ->> 'retired_by' = 'retire_item_unknown_variant'
AND mp.metadata ? 'status_before_retire';
-- Only while the item_id columns still exist (a second run finds them dropped).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'rate' AND table_name = 'products' AND column_name = 'item_id') THEN
EXECUTE $q$
UPDATE rate.products p
SET cluster_id = mp.id,
metadata = p.metadata - 'retired_unknown_variant' - 'variant_source_before_retire'
FROM rate.master_products mp
WHERE p.cluster_id IS NULL
AND p.metadata ? 'retired_unknown_variant'
AND mp.id = (p.metadata ->> 'retired_unknown_variant')::uuid
AND mp.item_id IS NOT DISTINCT FROM p.item_id
$q$;
END IF;
END $$;
DO $$
DECLARE
v_id uuid;
BEGIN
FOR v_id IN SELECT m.id FROM rate.master_products m ORDER BY m.id LOOP
PERFORM rate.refresh_master_market_stats(v_id);
PERFORM rate.refresh_master_product_rating(v_id);
END LOOP;
END $$;

-- 3. New functions.
DROP FUNCTION IF EXISTS rate.retire_item_unknown_variant(uuid);
DROP FUNCTION IF EXISTS rate.match_item(text, text, text, real);
DROP FUNCTION IF EXISTS rate.refresh_item_stats(uuid);
DROP FUNCTION IF EXISTS rate.refresh_item_rating(uuid);
DROP FUNCTION IF EXISTS rate.products_recheck_retired_variant();
DROP FUNCTION IF EXISTS rate.products_refresh_left();
DROP FUNCTION IF EXISTS rate.products_sync_item();
DROP FUNCTION IF EXISTS rate.master_products_move_listings();
DROP FUNCTION IF EXISTS rate.master_products_sync_item();
DROP FUNCTION IF EXISTS rate.item_name_from_master(text);
DROP FUNCTION IF EXISTS rate.item_slug_from_master(text, text);

-- 4. Constraints, indexes and columns on the existing tables.
ALTER TABLE rate.products DROP CONSTRAINT IF EXISTS fk_products_item;
ALTER TABLE rate.products DROP CONSTRAINT IF EXISTS products_variant_source_check;
ALTER TABLE rate.master_products DROP CONSTRAINT IF EXISTS fk_master_products_item;
DO $$
BEGIN
IF to_regclass('rate.items') IS NOT NULL THEN
ALTER TABLE rate.items DROP CONSTRAINT IF EXISTS fk_items_default_variant;
END IF;
END $$;
DROP INDEX IF EXISTS rate.idx_rate_products_item;
DROP INDEX IF EXISTS rate.idx_master_products_item;
DROP INDEX IF EXISTS rate.uq_master_products_item_variant;
ALTER TABLE rate.products DROP COLUMN IF EXISTS variant_source;
ALTER TABLE rate.products DROP COLUMN IF EXISTS item_id;
ALTER TABLE rate.master_products DROP COLUMN IF EXISTS sort_order;
ALTER TABLE rate.master_products DROP COLUMN IF EXISTS variant_key;
ALTER TABLE rate.master_products DROP COLUMN IF EXISTS item_id;

-- 5. The Item table (its policies, grants and indexes go with it).
DROP TABLE IF EXISTS rate.items;

COMMIT;
Loading
Loading