Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ jobs:
- name: Replay the DOS-Me Item RPC and M3/M4 proposals (Docker)
run: node scripts/test-m3-m4-item-rpcs-proposal.mjs

- name: Replay the DOS-Me criteria ratings proposal (Docker)
run: node scripts/test-criteria-ratings-proposal.mjs

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

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ playwright-report-item-model/
!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
!Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql
!Docs/Proposals/2026-10-31-dos-me-criteria-ratings-rollback.sql
!Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql
* - Copy*
* - Bản sao*
*.dump
Expand Down
46 changes: 46 additions & 0 deletions Docs/Proposals/2026-10-31-dos-me-criteria-ratings-rollback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- PROPOSAL, NOT APPLIED. Rollback of 2026-10-31-dos-me-criteria-ratings.sql (DOS/Rate#1543).
-- One transaction, re-runnable, safe before or after M1+M2 or its rollback: the migration
-- replaces no existing function, so this only drops what it added. Data written only into the
-- new columns and tables (exact stars, verdicts, pro and con lines, product points,
-- rating_score, criteria_scores) is lost; nothing older depends on it. Run it only while no
-- phase reads or writes them: before P3 (#1544), P4 (#1545) and P5 (#1546), or after they are
-- reverted.

BEGIN;

-- 1. Triggers first, so the drops below fire nothing.
DROP TRIGGER IF EXISTS trg_reviews_rating_extras_ins ON rate.reviews;
DROP TRIGGER IF EXISTS trg_reviews_rating_extras_upd ON rate.reviews;
DROP TRIGGER IF EXISTS trg_reviews_rating_extras_del ON rate.reviews;
DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_ins ON rate.review_flags;
DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_upd ON rate.review_flags;
DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_del ON rate.review_flags;
DROP TRIGGER IF EXISTS trg_master_products_rating_extras ON rate.master_products;

-- 2. Functions.
DROP FUNCTION IF EXISTS rate.master_products_refresh_rating_extras();
DROP FUNCTION IF EXISTS rate.review_flags_refresh_rating_extras();
DROP FUNCTION IF EXISTS rate.reviews_refresh_rating_extras();
DROP FUNCTION IF EXISTS rate.refresh_rating_extras_of_products(uuid[]);
DROP FUNCTION IF EXISTS rate.refresh_master_rating_extras(uuid);
DROP FUNCTION IF EXISTS rate.master_rating_extras(uuid);
DROP FUNCTION IF EXISTS rate.master_rating_item(uuid);

-- 3. Tables (their policies and indexes go with them).
DROP TABLE IF EXISTS rate.product_points;
DROP TABLE IF EXISTS rate.review_points;

-- 4. Columns (their CHECK constraints go with them).
ALTER TABLE rate.master_products
DROP COLUMN IF EXISTS criteria_scores,
DROP COLUMN IF EXISTS rating_weight_sum,
DROP COLUMN IF EXISTS rating_score;

ALTER TABLE rate.reviews
DROP COLUMN IF EXISTS value_breakdown,
DROP COLUMN IF EXISTS value_score,
DROP COLUMN IF EXISTS criteria_version,
DROP COLUMN IF EXISTS verdict,
DROP COLUMN IF EXISTS rating_exact;

COMMIT;
68 changes: 68 additions & 0 deletions Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
-- PROPOSAL. Read-only checks for 2026-10-31-dos-me-criteria-ratings.sql (DOS/Rate#1543). Run
-- after it. `scripts/test-criteria-ratings-proposal.mjs` runs the expect-0 ones on the replay
-- stack.

-- V1 Every new column exists. expect 0
SELECT 10 - count(*) AS missing_columns
FROM information_schema.columns
WHERE table_schema = 'rate'
AND ((table_name = 'reviews' AND column_name IN ('rating_exact', 'verdict', 'criteria_version',
'value_score', 'value_breakdown'))
OR (table_name = 'master_products' AND column_name IN ('rating_score', 'rating_weight_sum',
'criteria_scores'))
OR (table_name = 'review_points' AND column_name = 'body')
OR (table_name = 'product_points' AND column_name = 'label'));

-- V2 Both new tables have row level security on. expect 0
SELECT 2 - count(*) AS tables_without_rls
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'rate' AND c.relname IN ('review_points', 'product_points')
AND c.relrowsecurity;

-- V3 Each new function exists once, and no client role can run it. expect 0
-- Client roles: PUBLIC, anon, authenticated.
SELECT count(*) FILTER (WHERE to_regprocedure(w.signature) IS NULL)
+ count(*) FILTER (WHERE to_regprocedure(w.signature) IS NOT NULL AND (
has_function_privilege('anon', to_regprocedure(w.signature), 'EXECUTE')
OR has_function_privilege('authenticated', to_regprocedure(w.signature), 'EXECUTE')
OR EXISTS (SELECT 1 FROM pg_proc p, aclexplode(p.proacl) a
WHERE p.oid = to_regprocedure(w.signature) AND a.grantee = 0)))
AS missing_or_open_functions
FROM (VALUES ('rate.master_rating_item(uuid)'), ('rate.master_rating_extras(uuid)'),
('rate.refresh_master_rating_extras(uuid)'),
('rate.refresh_rating_extras_of_products(uuid[])'),
('rate.reviews_refresh_rating_extras()'),
('rate.review_flags_refresh_rating_extras()'),
('rate.master_products_refresh_rating_extras()')) w(signature);

-- V4 All seven triggers exist and are enabled. expect 0
SELECT 7 - count(*) AS missing_triggers
FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'rate' AND NOT t.tgisinternal AND t.tgenabled <> 'D'
AND t.tgname IN ('trg_reviews_rating_extras_ins', 'trg_reviews_rating_extras_upd',
'trg_reviews_rating_extras_del', 'trg_review_flags_rating_extras_ins',
'trg_review_flags_rating_extras_upd', 'trg_review_flags_rating_extras_del',
'trg_master_products_rating_extras');

-- V5 Every criterion score meets the display thresholds of D9. expect 0
SELECT count(*) AS criteria_below_threshold
FROM rate.master_products m, jsonb_each(m.criteria_scores) c(key, v)
WHERE (c.v ->> 'writers')::int < 3
OR (c.v ->> 'weight_sum')::numeric < 1.5
OR (c.v ->> 'score')::numeric NOT BETWEEN 1 AND 5;

-- V6 Every master holds what its reviews give today. expect 0
-- No stale, missing or wrong rating or criteria: catches a skipped backfill and a missed trigger.
SELECT count(*) AS masters_out_of_date
FROM rate.master_products m
CROSS JOIN LATERAL rate.master_rating_extras(m.id) x
WHERE (m.rating_score, m.rating_weight_sum, m.criteria_scores)
IS DISTINCT FROM (x.rating_score, x.rating_weight_sum, x.criteria_scores);

-- V7 Information: the rating each page will read, next to today's weighted mean.
SELECT m.slug, m.user_rating_weighted, m.rating_score, m.rating_weight_sum,
(SELECT count(*) FROM jsonb_object_keys(m.criteria_scores)) AS criteria_with_scores
FROM rate.master_products m
WHERE m.authentic_review_count > 0
ORDER BY m.authentic_review_count DESC
LIMIT 20;
86 changes: 86 additions & 0 deletions Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Proposal: DOS-Me migration for criteria ratings, pros and cons (DOS/Rate#1543)

**Status:** proposal for JOY, **not applied**. A schema change on the shared Supabase: JOY approves and applies it through DOS-Me (preview, then beta, then prod). Nothing runs from DOS/Rate.

Design: [`2026-10-31-criteria-stars-pros-cons.html`](./2026-10-31-criteria-stars-pros-cons.html), section 3; phase P2 of #1531, decisions D2, D3, D9 and D10 approved by JOY on 2026-10-31.

## Files

- [`2026-10-31-dos-me-criteria-ratings.sql`](./2026-10-31-dos-me-criteria-ratings.sql): the migration. Idempotent, in two transactions: the schema change first, then a backfill of the reviewed masters.
- [`2026-10-31-dos-me-criteria-ratings-verify.sql`](./2026-10-31-dos-me-criteria-ratings-verify.sql): read-only checks. V1 to V6 must return 0 after applying:
- V3: function grants for PUBLIC, anon and authenticated.
- V4: the triggers.
- V6: recomputes every master and compares it with what is stored.
- V7: lists `rating_score` next to today's `user_rating_weighted` for the 20 most-reviewed masters.
- [`2026-10-31-dos-me-criteria-ratings-rollback.sql`](./2026-10-31-dos-me-criteria-ratings-rollback.sql): drops everything the migration added. One transaction, re-runnable.
- [`scripts/test-criteria-ratings-proposal.mjs`](../../scripts/test-criteria-ratings-proposal.mjs): the CI replay in Docker, in four steps:
1. **Reference stack:** apply twice; check the ratings, the triggers (exact star, a flag that leaves the rating, the weight cap) and the grants; roll back twice.
2. **Before M1+M2:** apply, then run M0 and M1+M2. Two sizes become one Item, both variants show the pooled rating, and a vote on the Item path updates it.
3. **M1+M2 rollback** with this migration in place.
4. **After M1+M2:** M1+M2 again, then this migration on top, then the rollback.

## What it adds

- **`rate.reviews`:**
- `rating_exact numeric(2,1)`: the 0.1-step star a criteria review computes. `rating_star` stays the rounded integer that existing code reads.
- `verdict` (300 characters at most).
- `criteria_version`.
- `value_score` (0 to 100) and `value_breakdown`, for P6.
- **`rate.review_points`:** one pro or con line of a review, for the whole review or one criterion.
- Each line is 5 to 150 characters.
- `method` is `writer` or `ai`.
- `cluster_id` is filled by P5.
- Public read; only the service role writes.
- **`rate.product_points`:** the aggregated lines of a master, with support, writer count, an expert flag, and first and last seen dates. Public read; the service role and the worker (P5) write.
- **`rate.master_products`:**
- `rating_score`: the one rating of D3. It is a Bayesian average with k = 3 and mu = 4.2 (the same formula as the web's `computeBayesianRatingSummary`). Each review weight is capped at 3, and `rating_exact` is used when present. It is stored as `numeric(4,3)`, and criterion scores at 3 decimals too, so a page rounds once, to 1 decimal (4.249 shows 4.2).
- `rating_weight_sum`.
- `criteria_scores`: `{key: {score, weight_sum, writers}}`. A criterion appears only when all of these hold (D9):
- 3 or more reviews rated it;
- each rating is a number from 1 to 5, under a key shaped like the shared-data keys;
- the reviews' weight sum is 1.5 or more.

Readers apply the ranking threshold of 5 from `writers`.
- **Which reviews count:** the same reviews `refresh_master_product_rating` counts.
- Before M1+M2: the master's own listings.
- Once the master belongs to an Item: every listing of the Item, and the result is written to every variant, as `refresh_item_rating` does.
- The `item_id` columns are read dynamically, so the same functions work with or without M1+M2.
- **Functions:**
- `rate.master_rating_extras(master)` computes the values. It is read-only; the verify file uses it.
- `rate.refresh_master_rating_extras(master)` writes them, only where they changed.
- Only the service role can run them.
- **Triggers keep the values current.**
- On `rate.reviews` (a change of a rating input: star, exact star, criteria, weight, type, origin or listing) and `rate.review_flags` (`include_in_rating`), for every insert, update and delete. They run once per statement; see Known limits for bulk flag imports.
- On `rate.master_products`: a change of `user_rating_weighted` or `authentic_review_count`, for example after a listing moves.
- **No existing function is replaced or renamed.** A later `CREATE OR REPLACE` of a refresh function (M1+M2, its rollback, a hotfix) cannot stop these updates.

## Order of operations

1. **Apply order:** before or after M0 and M1+M2. The replay covers both orders and the M1+M2 rollback.
2. **Apply on preview:**
- The schema transaction takes ACCESS EXCLUSIVE locks on `rate.reviews` and `rate.master_products` only for the `ALTER TABLE` statements. The new CHECKs are added NOT VALID, so nothing is scanned under that lock.
- The backfill transaction validates the CHECKs (a scan that lets reads and writes go on) and refreshes the reviewed masters, locking only those rows.
- Run the verify file: V1 to V6 must return 0.
- Compare V7: `rating_score` sits a little below `user_rating_weighted` for masters with few reviews, because of the prior. That is intended (D3).
3. **Then beta, then prod**, running the verify file after each.
4. **If M1+M2 is ever rolled back while this is in place:** run the backfill again (section 8 of the migration, its last transaction), then V6. Variants whose reviews did not change keep the Item's pooled values until then; V6 lists them.
5. **Next phases:**
- P4 (#1545) switches the web to `rating_score`.
- P3 (#1544) writes the new review columns and lines.
- P5 (#1546) writes `product_points`.

## Rollback

`2026-10-31-dos-me-criteria-ratings-rollback.sql` drops the triggers, the functions, both tables and the new columns. It is safe before or after M1+M2, because the migration replaced nothing. Data written only into these objects is lost; nothing older depends on it.

Roll back only while no phase reads or writes the new columns: before P3, P4 and P5, or after reverting them.

## Known limits

- **Bulk flag imports.** The existing `trg_review_flags_sync_weight` updates `rate.reviews` one row at a time. A bulk flag import therefore refreshes a master once per flag row, not once per import. Re-crawls that rewrite reviews unchanged refresh nothing: only changes to rating inputs count.
- **Concurrent writers.** A variant locked by another transaction is skipped, as `refresh_item_rating` does. This avoids deadlocks between sibling variants. The other transaction writes its own values when it reaches that variant. Like the live refresh, two writers on the same master can leave the older values until the next write; V6 shows any master out of date.
- **Deleting a listing with its reviews** leaves the master's values in place until its next review write or refresh, the same as `user_rating_weighted` today. V6 shows it. Re-run the backfill to fix it.

- **`rate_worker` can write the new columns directly.** It keeps its table-level UPDATE on `master_products` and on crawled reviews (20261018183000). The next trigger run overwrites such a write, and V6 shows it. Column-level grants are a separate hardening step.
- **Per-category prior means:** version 1 uses 4.2 for every criterion.
- **Not in this migration:** `submit_community_review` v2 (P3) and the value score weight (P6). Each comes with its own phase.
Loading
Loading