Skip to content

Commit e48a2e7

Browse files
committed
feat(install): track CLI installs with deduplicated counting
Add install_count tracking when CLI `skillx use` runs. New `installs` table with partial unique indexes for per-user/device dedup. API endpoint accepts optional API key or device fingerprint. Fire-and-forget POST from CLI doesn't block UX. Atomic install_count increment + score recomputation on new installs only.
1 parent ccf073d commit e48a2e7

16 files changed

Lines changed: 2199 additions & 0 deletions

apps/web/app/lib/db/schema.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sql } from "drizzle-orm";
12
import { sqliteTable, text, integer, real, uniqueIndex, index } from "drizzle-orm/sqlite-core";
23

34
// Skills - core marketplace entity
@@ -112,6 +113,29 @@ export const usageStats = sqliteTable(
112113
]
113114
);
114115

116+
// Installs - deduplicated install tracking per user/device
117+
export const installs = sqliteTable(
118+
"installs",
119+
{
120+
id: text("id").primaryKey(),
121+
skill_id: text("skill_id")
122+
.notNull()
123+
.references(() => skills.id, { onDelete: "cascade" }),
124+
user_id: text("user_id"),
125+
device_id: text("device_id"),
126+
created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(),
127+
},
128+
(table) => [
129+
index("idx_installs_skill").on(table.skill_id),
130+
uniqueIndex("idx_installs_user")
131+
.on(table.skill_id, table.user_id)
132+
.where(sql`user_id IS NOT NULL`),
133+
uniqueIndex("idx_installs_device")
134+
.on(table.skill_id, table.device_id)
135+
.where(sql`device_id IS NOT NULL`),
136+
]
137+
);
138+
115139
// Better Auth tables — required by drizzle adapter
116140
export const user = sqliteTable("user", {
117141
id: text("id").primaryKey(),

apps/web/app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default [
1616
route("api/skills/:slug/rate", "routes/api.skill-rate.ts"),
1717
route("api/skills/:slug/review", "routes/api.skill-review.ts"),
1818
route("api/skills/:slug/favorite", "routes/api.skill-favorite.ts"),
19+
route("api/skills/:slug/install", "routes/api.skill-install.ts"),
1920
route("api/report", "routes/api.usage-report.ts"),
2021
route("api/user/api-keys", "routes/api.user-api-keys.ts"),
2122
route("*", "routes/$.tsx"),
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { ActionFunctionArgs } from "react-router";
2+
import { getDb } from "~/lib/db";
3+
import { skills, installs, apiKeys } from "~/lib/db/schema";
4+
import { eq, and, isNull, sql } from "drizzle-orm";
5+
import { verifyApiKey } from "~/lib/auth/api-key-utils";
6+
import { recomputeSkillScores } from "~/lib/leaderboard/recompute-skill-scores";
7+
8+
export async function action({ request, params, context }: ActionFunctionArgs) {
9+
try {
10+
const slug = params.slug;
11+
if (!slug) {
12+
return Response.json({ error: "Skill slug required" }, { status: 400 });
13+
}
14+
15+
const env = context.cloudflare.env as Env;
16+
const db = getDb(env.DB);
17+
18+
// Resolve identity: API key (user_id) or device fingerprint
19+
let userId: string | null = null;
20+
const deviceId = request.headers.get("X-Device-Id");
21+
const authHeader = request.headers.get("Authorization");
22+
23+
if (authHeader?.startsWith("Bearer ")) {
24+
const apiKeyPlaintext = authHeader.substring(7);
25+
const prefix = apiKeyPlaintext.substring(0, 8);
26+
const [foundKey] = await db
27+
.select()
28+
.from(apiKeys)
29+
.where(and(eq(apiKeys.key_prefix, prefix), isNull(apiKeys.revoked_at)))
30+
.limit(1);
31+
32+
if (foundKey) {
33+
const isValid = await verifyApiKey(apiKeyPlaintext, foundKey.key_hash);
34+
if (isValid) {
35+
userId = foundKey.user_id;
36+
await db
37+
.update(apiKeys)
38+
.set({ last_used_at: new Date() })
39+
.where(eq(apiKeys.id, foundKey.id));
40+
}
41+
}
42+
}
43+
44+
// Need at least one identifier
45+
if (!userId && !deviceId) {
46+
return Response.json(
47+
{ error: "Authorization header or X-Device-Id required" },
48+
{ status: 400 },
49+
);
50+
}
51+
52+
// Find skill
53+
const [skill] = await db
54+
.select({ id: skills.id })
55+
.from(skills)
56+
.where(eq(skills.slug, slug))
57+
.limit(1);
58+
59+
if (!skill) {
60+
return Response.json({ error: "Skill not found" }, { status: 404 });
61+
}
62+
63+
// Insert install (ON CONFLICT DO NOTHING) and check if row was inserted
64+
const installId = `inst-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
65+
let inserted = false;
66+
67+
try {
68+
await db.insert(installs).values({
69+
id: installId,
70+
skill_id: skill.id,
71+
user_id: userId,
72+
device_id: userId ? null : deviceId, // Nullify device_id when user_id present to prevent double-counting
73+
created_at: new Date(),
74+
});
75+
inserted = true;
76+
} catch (e: unknown) {
77+
// UNIQUE constraint violation = already installed (dedup)
78+
const msg = e instanceof Error ? e.message : "";
79+
if (!msg.includes("UNIQUE constraint")) throw e;
80+
}
81+
82+
if (inserted) {
83+
// Atomic increment
84+
await db
85+
.update(skills)
86+
.set({ install_count: sql`install_count + 1`, updated_at: new Date() })
87+
.where(eq(skills.id, skill.id));
88+
89+
await recomputeSkillScores(db, skill.id);
90+
}
91+
92+
return Response.json({ installed: inserted });
93+
} catch (error) {
94+
console.error("Error tracking install:", error);
95+
return Response.json(
96+
{ error: "Failed to track install" },
97+
{ status: 500 },
98+
);
99+
}
100+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CREATE TABLE `installs` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`skill_id` text NOT NULL,
4+
`user_id` text,
5+
`device_id` text,
6+
`created_at` integer NOT NULL,
7+
FOREIGN KEY (`skill_id`) REFERENCES `skills`(`id`) ON UPDATE no action ON DELETE cascade
8+
);
9+
--> statement-breakpoint
10+
CREATE INDEX `idx_installs_skill` ON `installs` (`skill_id`);--> statement-breakpoint
11+
CREATE UNIQUE INDEX `idx_installs_user` ON `installs` (`skill_id`,`user_id`) WHERE user_id IS NOT NULL;--> statement-breakpoint
12+
CREATE UNIQUE INDEX `idx_installs_device` ON `installs` (`skill_id`,`device_id`) WHERE device_id IS NOT NULL;

0 commit comments

Comments
 (0)