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:
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface PluginRow {
|
||||
capabilities: string;
|
||||
keywords: string | null;
|
||||
has_icon: number;
|
||||
install_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
Reference in New Issue
Block a user