fix: use stable site hash for install telemetry deduplication (#298)

* fix: use stable site hash for install telemetry deduplication (#297)

generateSiteHash() used Date.now() as the hash seed, producing a different
hash on every call. Since the installs table uses PRIMARY KEY (plugin_id,
site_hash), the same site could insert unlimited rows, inflating install
counts and making "Most Popular" sorting meaningless.

Fix: use the site's request origin as a stable hash seed. The same origin
always produces the same hash, so the marketplace deduplicates correctly.

Also denormalizes install_count on the plugins table to avoid a COUNT(*)
subquery per row in searchPlugins(). The count is recalculated atomically
on each upsertInstall() call.

Fixes #297

* chore: add changeset for install telemetry fix

* fix: address review feedback on install telemetry

- Replace crypto.subtle fallback with FNV-1a hash to avoid origin
  leakage and collisions from truncated seed strings
- Remove duplicate p.install_count from SELECT (p.* already includes it)
- Use explicit p.install_count in ORDER BY clause
- Use db.batch() for atomic upsert + count recomputation instead of
  separate statements with misleading meta.changes check
This commit is contained in:
Benjamin Price
2026-04-08 06:37:00 +09:00
committed by GitHub
parent 91e31fb2ca
commit f112ac4819
9 changed files with 94 additions and 36 deletions

View File

@@ -99,13 +99,12 @@ export async function searchPlugins(
break;
case "installs":
default:
orderBy = "install_count DESC, p.created_at DESC";
orderBy = "p.install_count DESC, p.created_at DESC";
break;
}
const query = `
SELECT p.*, a.name AS author_name, a.avatar_url AS author_avatar_url, a.verified AS author_verified,
(SELECT COUNT(*) FROM installs i WHERE i.plugin_id = p.id) AS install_count,
lv.version AS latest_version,
lv.status AS latest_status,
lv.audit_verdict AS latest_audit_verdict,
@@ -201,25 +200,25 @@ export async function getPluginVersion(
// ── Install queries ─────────────────────────────────────────────
export async function getInstallCount(db: D1Database, pluginId: string): Promise<number> {
const row = await db
.prepare("SELECT COUNT(*) AS count FROM installs WHERE plugin_id = ?")
.bind(pluginId)
.first<{ count: number }>();
return row?.count ?? 0;
}
export async function upsertInstall(
db: D1Database,
data: { pluginId: string; siteHash: string; version: string },
): Promise<void> {
await db
.prepare(
`INSERT INTO installs (plugin_id, site_hash, version) VALUES (?, ?, ?)
ON CONFLICT (plugin_id, site_hash) DO UPDATE SET version = excluded.version, installed_at = datetime('now')`,
)
.bind(data.pluginId, data.siteHash, data.version)
.run();
// Run the install upsert and install_count recomputation together so the
// plugin count stays consistent with the installs table.
await db.batch([
db
.prepare(
`INSERT INTO installs (plugin_id, site_hash, version) VALUES (?, ?, ?)
ON CONFLICT (plugin_id, site_hash) DO UPDATE SET version = excluded.version, installed_at = datetime('now')`,
)
.bind(data.pluginId, data.siteHash, data.version),
db
.prepare(
`UPDATE plugins SET install_count = (SELECT COUNT(*) FROM installs WHERE plugin_id = ?) WHERE id = ?`,
)
.bind(data.pluginId, data.pluginId),
]);
}
// ── Write queries ───────────────────────────────────────────────

View File

@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS plugins (
capabilities TEXT NOT NULL,
keywords TEXT,
has_icon INTEGER DEFAULT 0,
install_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -19,6 +19,7 @@ export interface PluginRow {
capabilities: string;
keywords: string | null;
has_icon: number;
install_count: number;
created_at: string;
updated_at: string;
}

View File

@@ -1,7 +1,6 @@
import { Hono } from "hono";
import {
getInstallCount,
getLatestVersion,
getPluginVersion,
getPluginVersions,
@@ -100,7 +99,7 @@ publicRoutes.get("/plugins/:id", async (c) => {
if (!plugin) return c.json({ error: "Plugin not found" }, 404);
const latestVersion = await getLatestVersion(c.env.DB, id);
const installCount = await getInstallCount(c.env.DB, id);
const installCount = plugin.install_count ?? 0;
const capabilities = safeJsonParse<string[]>(plugin.capabilities, []);
const keywords = safeJsonParse<string[]>(plugin.keywords, []);