openvibers/openvibe.meme Commits
Settle the hidden duplicates and keep the merged 301s (#161)
Changes
@@@ -18,7 +18,14 @@ What changed in OpenVibe.Meme, newest first.the oldest entry, moves their citations, votes, reports, judgements, origins, spread, examples, variations, relatedrows and revision history, records the merge in the kept entry's history, and turns the old slug and id into 301redirects (`meme_entry_redirects`).--- **Tests.** Two ingests of one normalised title stay one entry; 0009 moves everything and old addresses 301.++- **The hidden duplicates 0009 left.** The old candidate miner kept inserting hidden candidates after 0009: it++ deduped the phrase's slug before the judge rewrote the title, so a new window of the same line became a hidden row++ with the public entry's title and its own citations — and one took back `close-and-enemies`, hiding the 301.++ Migration 0011 merges every hidden entry that shares a public entry's `(kind, title_key)` into the public keeper,++ moves its citations, votes and history, and turns its old slug/id into 301s. A redirect now wins over a hidden row++ for readers (staff and the author still see it), and a new entry can never take a merged slug back.++- **Tests.** Two ingests of one normalised title stay one entry; 0009 moves everything and old addresses 301; 0011++ merges a hidden duplicate and the merged slug still 301s.## 0.1.0 — 2026-10-28@@@ -77,7 +77,7 @@ Search Fabric document.| `meme_examples` | entry, `med_` Media id, caption, source URL — reposted pictures keep their source link | `0003_encyclopedia.sql` || `meme_entry_related` | directed related pairs; the API returns both sides' public entries | `0003_encyclopedia.sql` || `meme_entry_revisions` | entry, revision, author, `change` jsonb, field-by-field `diff`, moderation state `pending\|approved\|rejected`, reviewer and note | `0003_encyclopedia.sql` |--| `meme_entry_redirects` | a merged entry's old slug/id → the kept entry; both answer 301 | `0009_entry_title_key.sql` |++| `meme_entry_redirects` | a merged entry's old slug/id → the kept entry; both answer 301, and a redirect wins over a hidden row that reused the slug | `0009_entry_title_key.sql`, `0011_merge_hidden_duplicates.sql` || `meme_votes` | (`week` `YYYY-Www`, `subject`) → entry; one vote a week | `0001_initial.sql` || `meme_templates` | id, name, `image_url` (openvibe.media, a local data-free placeholder or a Wikimedia Commons file), `box_count`, source, license, credit, source page, attribution; three placeholders seeded | `0001_initial.sql`, `0002_judge_and_encyclopedia.sql` || `meme_captions` | `mcp_` + ULID, subject, template, `lines` jsonb (1–4 plain-text lines), visibility, created | `0001_initial.sql` |nnew file mode 100644@@@ -0,0 +1,159 @@++-- phase: expand++-- 0009 stopped two *public* entries from sharing a title, but the old candidate miner kept inserting hidden++-- candidates: it deduped the phrase's slug before the judge rewrote the title, so a new window of the same++-- running joke became a hidden row with the public entry's title, its own slug and its own citations. Those++-- rows cannot be published (the public unique index refuses them), they never appear on Trending, and one of++-- them took back the old slug `close-and-enemies`, hiding the 301 that 0009 had created. The miner is gone++-- (the importer only enriches known entries now), so this expands 0009 to settle the hidden rows it left.++--++-- Every hidden entry with the same (kind, title_key) as a public entry is merged into that public entry: its++-- citations, votes, reports, judgements, origins, spread, examples, variations, related rows and history move,++-- one merge event is written to the kept entry, and its old slug and id become 301 redirects. A redirect that++-- already points at the hidden row is retargeted first, so no link is lost when the row is deleted.++++CREATE FUNCTION meme_merge_duplicate_entries() RETURNS integer++LANGUAGE plpgsql AS $$++DECLARE++ dup record;++ keeper_revision bigint;++ merged_ids text[] := '{}';++ moved integer := 0;++BEGIN++ FOR dup IN++ SELECT e.id, e.slug, e.kind, e.revision, e.first_seen_at, e.last_seen_at,++ k.id AS keeper_id, k.slug AS keeper_slug, k.status AS keeper_status++ FROM meme_entries e++ JOIN LATERAL (++ SELECT p.*++ FROM meme_entries p++ WHERE p.kind = e.kind AND p.title_key = e.title_key AND p.visibility = 'public'++ ORDER BY p.created_at ASC, p.id ASC++ LIMIT 1++ ) k ON true++ WHERE e.id <> k.id AND e.visibility IN ('public', 'hidden')++ ORDER BY e.kind, e.title_key, e.created_at, e.id++ LOOP++ -- Citations: the keeper's own copy of a moment wins; a second origin becomes a use.++ DELETE FROM meme_citations c++ WHERE c.entry_id = dup.id++ AND EXISTS (++ SELECT 1 FROM meme_citations k++ WHERE k.entry_id = dup.keeper_id++ AND k.source_service = c.source_service AND k.source_type = c.source_type++ AND k.source_id = c.source_id++ AND COALESCE(k.offset_seconds, -1) = COALESCE(c.offset_seconds, -1)++ );++ IF EXISTS (SELECT 1 FROM meme_citations WHERE entry_id = dup.keeper_id AND role = 'origin') THEN++ UPDATE meme_citations SET role = 'use' WHERE entry_id = dup.id AND role = 'origin';++ END IF;++ UPDATE meme_citations SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++++ -- The week's votes are a (week, subject) key: move the ones the keeper has no ballot for.++ DELETE FROM meme_votes v++ WHERE v.entry_id = dup.id++ AND EXISTS (SELECT 1 FROM meme_votes k WHERE k.entry_id = dup.keeper_id AND k.week = v.week AND k.subject = v.subject);++ UPDATE meme_votes SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++++ UPDATE meme_status_events SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++ UPDATE meme_reports SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++++ -- One judgement, origin and deep dive per entry: the keeper's own row wins.++ IF EXISTS (SELECT 1 FROM meme_judgements WHERE entry_id = dup.keeper_id) THEN++ DELETE FROM meme_judgements WHERE entry_id = dup.id;++ ELSE++ UPDATE meme_judgements SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++ END IF;++ IF EXISTS (SELECT 1 FROM meme_origins WHERE entry_id = dup.keeper_id) THEN++ DELETE FROM meme_origins WHERE entry_id = dup.id;++ ELSE++ UPDATE meme_origins SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++ END IF;++ IF EXISTS (SELECT 1 FROM meme_deep_dives WHERE entry_id = dup.keeper_id) THEN++ DELETE FROM meme_deep_dives WHERE entry_id = dup.id;++ ELSE++ UPDATE meme_deep_dives SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++ END IF;++++ DELETE FROM meme_spread_points s++ WHERE s.entry_id = dup.id++ AND EXISTS (++ SELECT 1 FROM meme_spread_points k++ WHERE k.entry_id = dup.keeper_id AND k.observed_at = s.observed_at++ AND k.place = s.place AND k.url IS NOT DISTINCT FROM s.url++ );++ UPDATE meme_spread_points SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++++ UPDATE meme_variations SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++ UPDATE meme_examples SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++++ -- Related memes: a relation between the two duplicates disappears; a relation the keeper already has++ -- wins; the rest are rewritten to the keeper.++ DELETE FROM meme_entry_related++ WHERE (entry_id = dup.id AND related_id = dup.keeper_id)++ OR (entry_id = dup.keeper_id AND related_id = dup.id);++ DELETE FROM meme_entry_related r++ WHERE (r.entry_id = dup.id++ AND EXISTS (SELECT 1 FROM meme_entry_related k WHERE k.entry_id = dup.keeper_id AND k.related_id = r.related_id))++ OR (r.related_id = dup.id++ AND EXISTS (SELECT 1 FROM meme_entry_related k WHERE k.entry_id = r.entry_id AND k.related_id = dup.keeper_id));++ UPDATE meme_entry_related SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++ UPDATE meme_entry_related SET related_id = dup.keeper_id WHERE related_id = dup.id;++ DELETE FROM meme_entry_related WHERE entry_id = related_id;++++ -- Wiki history: keep the keeper's revision numbers; the duplicate's colliding ones go, the rest move over.++ DELETE FROM meme_entry_revisions v++ WHERE v.entry_id = dup.id++ AND EXISTS (SELECT 1 FROM meme_entry_revisions k WHERE k.entry_id = dup.keeper_id AND k.revision = v.revision);++ UPDATE meme_entry_revisions SET entry_id = dup.keeper_id WHERE entry_id = dup.id;++++ -- Search: a pending document for the merged id must not be delivered after the delete; queue the tombstone.++ DELETE FROM meme_outbox WHERE published_at IS NULL AND payload->>'id' = dup.id;++ INSERT INTO meme_outbox (event_id, topic, payload, created_at, next_attempt_at)++ VALUES ('evt_' || substr(dup.id, 5), 'meme.index_document.deleted',++ jsonb_build_object('owner', 'meme', 'type', 'entry', 'id', dup.id,++ 'revision', dup.revision + 1, 'deleted', true),++ now(), now())++ ON CONFLICT (event_id) DO NOTHING;++++ -- The kept entry takes the merged range, one revision and one history row per merge. The new revision is++ -- above the highest history row either entry brought, not just above the keeper's old counter.++ SELECT GREATEST(k.revision, COALESCE((SELECT MAX(r.revision) FROM meme_entry_revisions r WHERE r.entry_id = k.id), 0)) + 1++ INTO keeper_revision++ FROM meme_entries k WHERE k.id = dup.keeper_id;++++ UPDATE meme_entries++ SET revision = keeper_revision,++ first_seen_at = LEAST(first_seen_at, dup.first_seen_at),++ last_seen_at = GREATEST(last_seen_at, dup.last_seen_at),++ updated_at = now()++ WHERE id = dup.keeper_id;++++ INSERT INTO meme_entry_revisions (entry_id, revision, author, base_revision, change, diff, moderation_state, note, created_at, reviewed_at, reviewed_by)++ VALUES (dup.keeper_id, keeper_revision, 'service:meme:migration', keeper_revision - 1,++ jsonb_build_object('merge', jsonb_build_object('id', dup.id, 'slug', dup.slug)),++ jsonb_build_array(jsonb_build_object('field', 'merged', 'from', dup.slug, 'to', dup.keeper_slug)),++ 'approved', 'duplicate merged by migration 0011', now(), now(), 'service:meme:migration');++++ INSERT INTO meme_status_events (entry_id, from_status, to_status, reason, changed_by, decision, from_visibility, to_visibility)++ VALUES (dup.keeper_id, dup.keeper_status, dup.keeper_status,++ 'merged duplicate ' || dup.id || ' (/' || dup.slug || ') into /' || dup.keeper_slug,++ 'service:meme:migration', 'merge', 'public', 'public');++++ -- A redirect already pointing at this hidden row follows the merge, then the old slug and id get one.++ UPDATE meme_entry_redirects SET to_entry_id = dup.keeper_id WHERE to_entry_id = dup.id;++ INSERT INTO meme_entry_redirects (from_slug, from_entry_id, to_entry_id, reason)++ VALUES (dup.slug, dup.id, dup.keeper_id, 'duplicate merged by migration 0011')++ ON CONFLICT DO NOTHING;++++ merged_ids := merged_ids || dup.id;++ moved := moved + 1;++ END LOOP;++++ IF cardinality(merged_ids) > 0 THEN++ DELETE FROM meme_entries WHERE id = ANY(merged_ids);++ END IF;++ RETURN moved;++END++$$;++++SELECT meme_merge_duplicate_entries();@@@ -111,11 +111,15 @@ function createApi(ctx) {/** A merged slug or id answers 301 to the kept entry; null when there is no entry and no redirect. */async function bySlugOrRedirect(value, req, res, { suffix = '' } = {}) {const row = await entries.bySlug(value);-- if (row) return row;++ const staff = isStaff(req.principal);++ const mine = Boolean(row && row.submitted_by && req.principal.kind === 'user' && req.principal.subject === row.submitted_by);++ // A hidden candidate can share the old slug of a merged entry. Staff and its author still see it; for++ // everyone else the 301 wins, so a stale link does not become a 404.++ if (row && (row.visibility !== 'hidden' || staff || mine)) return row;const redirect = await entries.redirectFor(value);-- if (!redirect) return null;++ if (!redirect) return row;const target = await entries.byId(redirect.to_entry_id);-- if (!target || (target.visibility === 'hidden' && !isStaff(req.principal))) return null;++ if (!target || (target.visibility === 'hidden' && !staff)) return row;res.redirect(301, `/api/v1/entries/${encodeURIComponent(target.slug)}${suffix}`);return undefined;}@@@ -105,9 +105,9 @@ function createPageRoutes(ctx) {const slug = String(req.params.slug || '');const staff = staffViewer(req);const row = await entries.bySlug(slug);-- if (!row) {++ if (!row || (row.visibility === 'hidden' && !staff)) {// A merged entry's old slug (or id) answers 301 to the entry it was merged into, so an old link lands-- // on the live page instead of a 404.++ // on the live page instead of a 404. A hidden candidate that reused the merged slug must not mask it.const redirect = await entries.redirectFor(slug);if (redirect) {const target = await entries.byId(redirect.to_entry_id);@@@ -717,10 +717,16 @@ function createStore(s, { search }) {/** The id for a slug, appending -2, -3… when one is already taken. */async function uniqueSlug(base, t) {const root = String(base || 'meme').slice(0, 70).replace(/^-+|-+$/g, '') || 'meme';++ // A merged entry's old slug is still reserved: a later hidden candidate must not take it back and hide++ // the 301. The redirect table arrives in 0009, after the base schema some migration tests start from.++ const redirects = Boolean(await q(t).maybe(++ "SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'meme_entry_redirects'",++ ));let slug = root;for (let i = 2; i < 200; i++) {const found = await q(t).maybe('SELECT id FROM meme_entries WHERE slug = $1', [slug]);-- if (!found) return slug;++ const redirected = redirects && await q(t).maybe('SELECT 1 FROM meme_entry_redirects WHERE from_slug = $1', [slug]);++ if (!found && !redirected) return slug;slug = `${root}-${i}`;}return `${root}-${Date.now().toString(36)}`;@@@ -138,6 +138,62 @@ const ulid = () => ids.ulid();const keys = trending.map((e) => `${e.kind}:${e.title.toLowerCase()}`);assert.strictEqual(new Set(keys).size, keys.length, 'no repeated title in Trending: ' + JSON.stringify(keys));});++++ // The old candidate miner could insert a hidden candidate with the same title after 0009, and its slug++ // could take back the merged slug. That is how production got `close-and-enemies` 404 after the merge.++ const hiddenId = `mem_${ulid()}`;++ await db.exec(++ `INSERT INTO meme_entries (id, slug, title, kind, visibility, first_seen_at, last_seen_at)++ VALUES ($1, 'close-and-enemies', 'Keep friends close and enemies guessing', 'catchphrase', 'hidden', $2, $2)`,++ [hiddenId, now],++ );++ await t.ctx.entries.addCitations(hiddenId, [++ cite({ role: 'origin', source_id: '2', quote_text: 'Keep friends close and enemies guessing.' }),++ cite({ role: 'use', source_id: '9', quote_text: 'Keep friends close and enemies guessing, always.' }),++ ]);++++ await check('a hidden candidate that took a merged slug does not hide the 301', async () => {++ const page = await t.get('/m/close-and-enemies');++ assert.strictEqual(page.status, 301, 'the redirect must win over the hidden row: ' + page.status);++ assert.strictEqual(page.headers.get('location'), '/m/keep-friends-close');++ const api = await t.get('/api/v1/entries/close-and-enemies');++ assert.strictEqual(api.status, 301);++ assert.strictEqual(api.headers.get('location'), '/api/v1/entries/keep-friends-close');++ });++++ await check('0011 merges a hidden duplicate into the public entry and keeps the old address', async () => {++ const sql = fs.readFileSync(path.join(MIGRATIONS, '0011_merge_hidden_duplicates.sql'), 'utf8');++ await applyMigration(sql);++++ const hidden = await db.maybe('SELECT id FROM meme_entries WHERE id = $1', [hiddenId]);++ assert.ok(!hidden, 'the hidden duplicate row is gone');++ // Four citations from 0009; source 2 was already on the keeper, source 9 moved over.++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE entry_id = $1', [keeper.entry.id])), 5);++ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM meme_entry_revisions WHERE entry_id = $1 AND note = 'duplicate merged by migration 0011'", [keeper.entry.id])), 1);++ const redirect = await db.maybe('SELECT * FROM meme_entry_redirects WHERE from_slug = $1', ['close-and-enemies']);++ assert.strictEqual(redirect.to_entry_id, keeper.entry.id, 'the original merged slug still points at the keeper');++ assert.strictEqual(Number(await db.value(`++ SELECT COUNT(*)::int AS n FROM meme_entries h++ JOIN meme_entries p ON p.visibility = 'public' AND p.kind = h.kind AND p.title_key = h.title_key++ WHERE h.visibility = 'hidden'++ `)), 0, 'no hidden entry shares a public title');++++ const page = await t.get('/m/close-and-enemies');++ assert.strictEqual(page.status, 301);++ assert.strictEqual(page.headers.get('location'), '/m/keep-friends-close');++ const byId = await t.get(`/m/${dup.entry.id}`);++ assert.strictEqual(byId.status, 301);++ assert.strictEqual(byId.headers.get('location'), '/m/keep-friends-close');++ });++++ await check('a new slug never takes a merged one back', async () => {++ const made = await t.ctx.entries.createCandidate({++ slug: 'close-and-enemies', title: 'A different line', kind: 'catchphrase',++ first_seen_at: now, last_seen_at: now,++ citations: [cite({ role: 'origin', source_id: '10', quote_text: 'A different line.' })],++ });++ assert.strictEqual(made.entry.slug, 'close-and-enemies-2', 'the merged slug stays reserved for its 301');++ });} finally {await t.close();fs.rmSync(beforeDir, { recursive: true, force: true });
@@ -18,7 +18,14 @@ What changed in OpenVibe.Meme, newest first.
@@ -18,7 +18,14 @@ What changed in OpenVibe.Meme, newest first.
the oldest entry, moves their citations, votes, reports, judgements, origins, spread, examples, variations, related
the oldest entry, moves their citations, votes, reports, judgements, origins, spread, examples, variations, related
rows and revision history, records the merge in the kept entry's history, and turns the old slug and id into 301
rows and revision history, records the merge in the kept entry's history, and turns the old slug and id into 301
redirects (`meme_entry_redirects`).
redirects (`meme_entry_redirects`).
-- **Tests.** Two ingests of one normalised title stay one entry; 0009 moves everything and old addresses 301.
+- **The hidden duplicates 0009 left.** The old candidate miner kept inserting hidden candidates after 0009: it
+ deduped the phrase's slug before the judge rewrote the title, so a new window of the same line became a hidden row
+ with the public entry's title and its own citations — and one took back `close-and-enemies`, hiding the 301.
+ Migration 0011 merges every hidden entry that shares a public entry's `(kind, title_key)` into the public keeper,
+ moves its citations, votes and history, and turns its old slug/id into 301s. A redirect now wins over a hidden row
+ for readers (staff and the author still see it), and a new entry can never take a merged slug back.
+- **Tests.** Two ingests of one normalised title stay one entry; 0009 moves everything and old addresses 301; 0011
+ merges a hidden duplicate and the merged slug still 301s.
## 0.1.0 — 2026-10-28
## 0.1.0 — 2026-10-28
@@ -77,7 +77,7 @@ Search Fabric document.
@@ -77,7 +77,7 @@ Search Fabric document.
| `meme_examples` | entry, `med_` Media id, caption, source URL — reposted pictures keep their source link | `0003_encyclopedia.sql` |
| `meme_examples` | entry, `med_` Media id, caption, source URL — reposted pictures keep their source link | `0003_encyclopedia.sql` |
| `meme_entry_related` | directed related pairs; the API returns both sides' public entries | `0003_encyclopedia.sql` |
| `meme_entry_related` | directed related pairs; the API returns both sides' public entries | `0003_encyclopedia.sql` |
| `meme_entry_revisions` | entry, revision, author, `change` jsonb, field-by-field `diff`, moderation state `pending\|approved\|rejected`, reviewer and note | `0003_encyclopedia.sql` |
| `meme_entry_revisions` | entry, revision, author, `change` jsonb, field-by-field `diff`, moderation state `pending\|approved\|rejected`, reviewer and note | `0003_encyclopedia.sql` |
-| `meme_entry_redirects` | a merged entry's old slug/id → the kept entry; both answer 301 | `0009_entry_title_key.sql` |
+| `meme_entry_redirects` | a merged entry's old slug/id → the kept entry; both answer 301, and a redirect wins over a hidden row that reused the slug | `0009_entry_title_key.sql`, `0011_merge_hidden_duplicates.sql` |
| `meme_votes` | (`week` `YYYY-Www`, `subject`) → entry; one vote a week | `0001_initial.sql` |
| `meme_votes` | (`week` `YYYY-Www`, `subject`) → entry; one vote a week | `0001_initial.sql` |
| `meme_templates` | id, name, `image_url` (openvibe.media, a local data-free placeholder or a Wikimedia Commons file), `box_count`, source, license, credit, source page, attribution; three placeholders seeded | `0001_initial.sql`, `0002_judge_and_encyclopedia.sql` |
| `meme_templates` | id, name, `image_url` (openvibe.media, a local data-free placeholder or a Wikimedia Commons file), `box_count`, source, license, credit, source page, attribution; three placeholders seeded | `0001_initial.sql`, `0002_judge_and_encyclopedia.sql` |
| `meme_captions` | `mcp_` + ULID, subject, template, `lines` jsonb (1–4 plain-text lines), visibility, created | `0001_initial.sql` |
| `meme_captions` | `mcp_` + ULID, subject, template, `lines` jsonb (1–4 plain-text lines), visibility, created | `0001_initial.sql` |
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+-- phase: expand
+-- 0009 stopped two *public* entries from sharing a title, but the old candidate miner kept inserting hidden
+-- candidates: it deduped the phrase's slug before the judge rewrote the title, so a new window of the same
+-- running joke became a hidden row with the public entry's title, its own slug and its own citations. Those
+-- rows cannot be published (the public unique index refuses them), they never appear on Trending, and one of
+-- them took back the old slug `close-and-enemies`, hiding the 301 that 0009 had created. The miner is gone
+-- (the importer only enriches known entries now), so this expands 0009 to settle the hidden rows it left.
+--
+-- Every hidden entry with the same (kind, title_key) as a public entry is merged into that public entry: its
+-- citations, votes, reports, judgements, origins, spread, examples, variations, related rows and history move,
+-- one merge event is written to the kept entry, and its old slug and id become 301 redirects. A redirect that
+-- already points at the hidden row is retargeted first, so no link is lost when the row is deleted.
+
+CREATE FUNCTION meme_merge_duplicate_entries() RETURNS integer
+LANGUAGE plpgsql AS $$
+DECLARE
+ dup record;
+ keeper_revision bigint;
+ merged_ids text[] := '{}';
+ moved integer := 0;
+BEGIN
+ FOR dup IN
+ SELECT e.id, e.slug, e.kind, e.revision, e.first_seen_at, e.last_seen_at,
+ k.id AS keeper_id, k.slug AS keeper_slug, k.status AS keeper_status
+ FROM meme_entries e
+ JOIN LATERAL (
+ SELECT p.*
+ FROM meme_entries p
+ WHERE p.kind = e.kind AND p.title_key = e.title_key AND p.visibility = 'public'
+ ORDER BY p.created_at ASC, p.id ASC
+ LIMIT 1
+ ) k ON true
+ WHERE e.id <> k.id AND e.visibility IN ('public', 'hidden')
+ ORDER BY e.kind, e.title_key, e.created_at, e.id
+ LOOP
+ -- Citations: the keeper's own copy of a moment wins; a second origin becomes a use.
+ DELETE FROM meme_citations c
+ WHERE c.entry_id = dup.id
+ AND EXISTS (
+ SELECT 1 FROM meme_citations k
+ WHERE k.entry_id = dup.keeper_id
+ AND k.source_service = c.source_service AND k.source_type = c.source_type
+ AND k.source_id = c.source_id
+ AND COALESCE(k.offset_seconds, -1) = COALESCE(c.offset_seconds, -1)
+ );
+ IF EXISTS (SELECT 1 FROM meme_citations WHERE entry_id = dup.keeper_id AND role = 'origin') THEN
+ UPDATE meme_citations SET role = 'use' WHERE entry_id = dup.id AND role = 'origin';
+ END IF;
+ UPDATE meme_citations SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+
+ -- The week's votes are a (week, subject) key: move the ones the keeper has no ballot for.
+ DELETE FROM meme_votes v
+ WHERE v.entry_id = dup.id
+ AND EXISTS (SELECT 1 FROM meme_votes k WHERE k.entry_id = dup.keeper_id AND k.week = v.week AND k.subject = v.subject);
+ UPDATE meme_votes SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+
+ UPDATE meme_status_events SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+ UPDATE meme_reports SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+
+ -- One judgement, origin and deep dive per entry: the keeper's own row wins.
+ IF EXISTS (SELECT 1 FROM meme_judgements WHERE entry_id = dup.keeper_id) THEN
+ DELETE FROM meme_judgements WHERE entry_id = dup.id;
+ ELSE
+ UPDATE meme_judgements SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+ END IF;
+ IF EXISTS (SELECT 1 FROM meme_origins WHERE entry_id = dup.keeper_id) THEN
+ DELETE FROM meme_origins WHERE entry_id = dup.id;
+ ELSE
+ UPDATE meme_origins SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+ END IF;
+ IF EXISTS (SELECT 1 FROM meme_deep_dives WHERE entry_id = dup.keeper_id) THEN
+ DELETE FROM meme_deep_dives WHERE entry_id = dup.id;
+ ELSE
+ UPDATE meme_deep_dives SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+ END IF;
+
+ DELETE FROM meme_spread_points s
+ WHERE s.entry_id = dup.id
+ AND EXISTS (
+ SELECT 1 FROM meme_spread_points k
+ WHERE k.entry_id = dup.keeper_id AND k.observed_at = s.observed_at
+ AND k.place = s.place AND k.url IS NOT DISTINCT FROM s.url
+ );
+ UPDATE meme_spread_points SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+
+ UPDATE meme_variations SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+ UPDATE meme_examples SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+
+ -- Related memes: a relation between the two duplicates disappears; a relation the keeper already has
+ -- wins; the rest are rewritten to the keeper.
+ DELETE FROM meme_entry_related
+ WHERE (entry_id = dup.id AND related_id = dup.keeper_id)
+ OR (entry_id = dup.keeper_id AND related_id = dup.id);
+ DELETE FROM meme_entry_related r
+ WHERE (r.entry_id = dup.id
+ AND EXISTS (SELECT 1 FROM meme_entry_related k WHERE k.entry_id = dup.keeper_id AND k.related_id = r.related_id))
+ OR (r.related_id = dup.id
+ AND EXISTS (SELECT 1 FROM meme_entry_related k WHERE k.entry_id = r.entry_id AND k.related_id = dup.keeper_id));
+ UPDATE meme_entry_related SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+ UPDATE meme_entry_related SET related_id = dup.keeper_id WHERE related_id = dup.id;
+ DELETE FROM meme_entry_related WHERE entry_id = related_id;
+
+ -- Wiki history: keep the keeper's revision numbers; the duplicate's colliding ones go, the rest move over.
+ DELETE FROM meme_entry_revisions v
+ WHERE v.entry_id = dup.id
+ AND EXISTS (SELECT 1 FROM meme_entry_revisions k WHERE k.entry_id = dup.keeper_id AND k.revision = v.revision);
+ UPDATE meme_entry_revisions SET entry_id = dup.keeper_id WHERE entry_id = dup.id;
+
+ -- Search: a pending document for the merged id must not be delivered after the delete; queue the tombstone.
+ DELETE FROM meme_outbox WHERE published_at IS NULL AND payload->>'id' = dup.id;
+ INSERT INTO meme_outbox (event_id, topic, payload, created_at, next_attempt_at)
+ VALUES ('evt_' || substr(dup.id, 5), 'meme.index_document.deleted',
+ jsonb_build_object('owner', 'meme', 'type', 'entry', 'id', dup.id,
+ 'revision', dup.revision + 1, 'deleted', true),
+ now(), now())
+ ON CONFLICT (event_id) DO NOTHING;
+
+ -- The kept entry takes the merged range, one revision and one history row per merge. The new revision is
+ -- above the highest history row either entry brought, not just above the keeper's old counter.
+ SELECT GREATEST(k.revision, COALESCE((SELECT MAX(r.revision) FROM meme_entry_revisions r WHERE r.entry_id = k.id), 0)) + 1
+ INTO keeper_revision
+ FROM meme_entries k WHERE k.id = dup.keeper_id;
+
+ UPDATE meme_entries
+ SET revision = keeper_revision,
+ first_seen_at = LEAST(first_seen_at, dup.first_seen_at),
+ last_seen_at = GREATEST(last_seen_at, dup.last_seen_at),
+ updated_at = now()
+ WHERE id = dup.keeper_id;
+
+ INSERT INTO meme_entry_revisions (entry_id, revision, author, base_revision, change, diff, moderation_state, note, created_at, reviewed_at, reviewed_by)
+ VALUES (dup.keeper_id, keeper_revision, 'service:meme:migration', keeper_revision - 1,
+ jsonb_build_object('merge', jsonb_build_object('id', dup.id, 'slug', dup.slug)),
+ jsonb_build_array(jsonb_build_object('field', 'merged', 'from', dup.slug, 'to', dup.keeper_slug)),
+ 'approved', 'duplicate merged by migration 0011', now(), now(), 'service:meme:migration');
+
+ INSERT INTO meme_status_events (entry_id, from_status, to_status, reason, changed_by, decision, from_visibility, to_visibility)
+ VALUES (dup.keeper_id, dup.keeper_status, dup.keeper_status,
+ 'merged duplicate ' || dup.id || ' (/' || dup.slug || ') into /' || dup.keeper_slug,
+ 'service:meme:migration', 'merge', 'public', 'public');
+
+ -- A redirect already pointing at this hidden row follows the merge, then the old slug and id get one.
+ UPDATE meme_entry_redirects SET to_entry_id = dup.keeper_id WHERE to_entry_id = dup.id;
+ INSERT INTO meme_entry_redirects (from_slug, from_entry_id, to_entry_id, reason)
+ VALUES (dup.slug, dup.id, dup.keeper_id, 'duplicate merged by migration 0011')
+ ON CONFLICT DO NOTHING;
+
+ merged_ids := merged_ids || dup.id;
+ moved := moved + 1;
+ END LOOP;
+
+ IF cardinality(merged_ids) > 0 THEN
+ DELETE FROM meme_entries WHERE id = ANY(merged_ids);
+ END IF;
+ RETURN moved;
+END
+$$;
+
+SELECT meme_merge_duplicate_entries();
@@ -111,11 +111,15 @@ function createApi(ctx) {
@@ -111,11 +111,15 @@ function createApi(ctx) {
/** A merged slug or id answers 301 to the kept entry; null when there is no entry and no redirect. */
/** A merged slug or id answers 301 to the kept entry; null when there is no entry and no redirect. */
async function bySlugOrRedirect(value, req, res, { suffix = '' } = {}) {
async function bySlugOrRedirect(value, req, res, { suffix = '' } = {}) {
const row = await entries.bySlug(value);
const row = await entries.bySlug(value);
- if (row) return row;
+ const staff = isStaff(req.principal);
+ const mine = Boolean(row && row.submitted_by && req.principal.kind === 'user' && req.principal.subject === row.submitted_by);
+ // A hidden candidate can share the old slug of a merged entry. Staff and its author still see it; for
+ // everyone else the 301 wins, so a stale link does not become a 404.
+ if (row && (row.visibility !== 'hidden' || staff || mine)) return row;
const redirect = await entries.redirectFor(value);
const redirect = await entries.redirectFor(value);
- if (!redirect) return null;
+ if (!redirect) return row;
const target = await entries.byId(redirect.to_entry_id);
const target = await entries.byId(redirect.to_entry_id);
- if (!target || (target.visibility === 'hidden' && !isStaff(req.principal))) return null;
+ if (!target || (target.visibility === 'hidden' && !staff)) return row;
res.redirect(301, `/api/v1/entries/${encodeURIComponent(target.slug)}${suffix}`);
res.redirect(301, `/api/v1/entries/${encodeURIComponent(target.slug)}${suffix}`);
return undefined;
return undefined;
}
}
@@ -105,9 +105,9 @@ function createPageRoutes(ctx) {
@@ -105,9 +105,9 @@ function createPageRoutes(ctx) {
const slug = String(req.params.slug || '');
const slug = String(req.params.slug || '');
const staff = staffViewer(req);
const staff = staffViewer(req);
const row = await entries.bySlug(slug);
const row = await entries.bySlug(slug);
- if (!row) {
+ if (!row || (row.visibility === 'hidden' && !staff)) {
// A merged entry's old slug (or id) answers 301 to the entry it was merged into, so an old link lands
// A merged entry's old slug (or id) answers 301 to the entry it was merged into, so an old link lands
- // on the live page instead of a 404.
+ // on the live page instead of a 404. A hidden candidate that reused the merged slug must not mask it.
const redirect = await entries.redirectFor(slug);
const redirect = await entries.redirectFor(slug);
if (redirect) {
if (redirect) {
const target = await entries.byId(redirect.to_entry_id);
const target = await entries.byId(redirect.to_entry_id);
@@ -717,10 +717,16 @@ function createStore(s, { search }) {
@@ -717,10 +717,16 @@ function createStore(s, { search }) {
/** The id for a slug, appending -2, -3… when one is already taken. */
/** The id for a slug, appending -2, -3… when one is already taken. */
async function uniqueSlug(base, t) {
async function uniqueSlug(base, t) {
const root = String(base || 'meme').slice(0, 70).replace(/^-+|-+$/g, '') || 'meme';
const root = String(base || 'meme').slice(0, 70).replace(/^-+|-+$/g, '') || 'meme';
+ // A merged entry's old slug is still reserved: a later hidden candidate must not take it back and hide
+ // the 301. The redirect table arrives in 0009, after the base schema some migration tests start from.
+ const redirects = Boolean(await q(t).maybe(
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'meme_entry_redirects'",
+ ));
let slug = root;
let slug = root;
for (let i = 2; i < 200; i++) {
for (let i = 2; i < 200; i++) {
const found = await q(t).maybe('SELECT id FROM meme_entries WHERE slug = $1', [slug]);
const found = await q(t).maybe('SELECT id FROM meme_entries WHERE slug = $1', [slug]);
- if (!found) return slug;
+ const redirected = redirects && await q(t).maybe('SELECT 1 FROM meme_entry_redirects WHERE from_slug = $1', [slug]);
+ if (!found && !redirected) return slug;
slug = `${root}-${i}`;
slug = `${root}-${i}`;
}
}
return `${root}-${Date.now().toString(36)}`;
return `${root}-${Date.now().toString(36)}`;
@@ -138,6 +138,62 @@ const ulid = () => ids.ulid();
@@ -138,6 +138,62 @@ const ulid = () => ids.ulid();
const keys = trending.map((e) => `${e.kind}:${e.title.toLowerCase()}`);
const keys = trending.map((e) => `${e.kind}:${e.title.toLowerCase()}`);
assert.strictEqual(new Set(keys).size, keys.length, 'no repeated title in Trending: ' + JSON.stringify(keys));
assert.strictEqual(new Set(keys).size, keys.length, 'no repeated title in Trending: ' + JSON.stringify(keys));
});
});
+
+ // The old candidate miner could insert a hidden candidate with the same title after 0009, and its slug
+ // could take back the merged slug. That is how production got `close-and-enemies` 404 after the merge.
+ const hiddenId = `mem_${ulid()}`;
+ await db.exec(
+ `INSERT INTO meme_entries (id, slug, title, kind, visibility, first_seen_at, last_seen_at)
+ VALUES ($1, 'close-and-enemies', 'Keep friends close and enemies guessing', 'catchphrase', 'hidden', $2, $2)`,
+ [hiddenId, now],
+ );
+ await t.ctx.entries.addCitations(hiddenId, [
+ cite({ role: 'origin', source_id: '2', quote_text: 'Keep friends close and enemies guessing.' }),
+ cite({ role: 'use', source_id: '9', quote_text: 'Keep friends close and enemies guessing, always.' }),
+ ]);
+
+ await check('a hidden candidate that took a merged slug does not hide the 301', async () => {
+ const page = await t.get('/m/close-and-enemies');
+ assert.strictEqual(page.status, 301, 'the redirect must win over the hidden row: ' + page.status);
+ assert.strictEqual(page.headers.get('location'), '/m/keep-friends-close');
+ const api = await t.get('/api/v1/entries/close-and-enemies');
+ assert.strictEqual(api.status, 301);
+ assert.strictEqual(api.headers.get('location'), '/api/v1/entries/keep-friends-close');
+ });
+
+ await check('0011 merges a hidden duplicate into the public entry and keeps the old address', async () => {
+ const sql = fs.readFileSync(path.join(MIGRATIONS, '0011_merge_hidden_duplicates.sql'), 'utf8');
+ await applyMigration(sql);
+
+ const hidden = await db.maybe('SELECT id FROM meme_entries WHERE id = $1', [hiddenId]);
+ assert.ok(!hidden, 'the hidden duplicate row is gone');
+ // Four citations from 0009; source 2 was already on the keeper, source 9 moved over.
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE entry_id = $1', [keeper.entry.id])), 5);
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*)::int AS n FROM meme_entry_revisions WHERE entry_id = $1 AND note = 'duplicate merged by migration 0011'", [keeper.entry.id])), 1);
+ const redirect = await db.maybe('SELECT * FROM meme_entry_redirects WHERE from_slug = $1', ['close-and-enemies']);
+ assert.strictEqual(redirect.to_entry_id, keeper.entry.id, 'the original merged slug still points at the keeper');
+ assert.strictEqual(Number(await db.value(`
+ SELECT COUNT(*)::int AS n FROM meme_entries h
+ JOIN meme_entries p ON p.visibility = 'public' AND p.kind = h.kind AND p.title_key = h.title_key
+ WHERE h.visibility = 'hidden'
+ `)), 0, 'no hidden entry shares a public title');
+
+ const page = await t.get('/m/close-and-enemies');
+ assert.strictEqual(page.status, 301);
+ assert.strictEqual(page.headers.get('location'), '/m/keep-friends-close');
+ const byId = await t.get(`/m/${dup.entry.id}`);
+ assert.strictEqual(byId.status, 301);
+ assert.strictEqual(byId.headers.get('location'), '/m/keep-friends-close');
+ });
+
+ await check('a new slug never takes a merged one back', async () => {
+ const made = await t.ctx.entries.createCandidate({
+ slug: 'close-and-enemies', title: 'A different line', kind: 'catchphrase',
+ first_seen_at: now, last_seen_at: now,
+ citations: [cite({ role: 'origin', source_id: '10', quote_text: 'A different line.' })],
+ });
+ assert.strictEqual(made.entry.slug, 'close-and-enemies-2', 'the merged slug stays reserved for its 301');
+ });
} finally {
} finally {
await t.close();
await t.close();
fs.rmSync(beforeDir, { recursive: true, force: true });
fs.rmSync(beforeDir, { recursive: true, force: true });