pilothub 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.env.local.example +19 -0
  2. package/.github/workflows/ci.yml +40 -0
  3. package/.oxlintrc.json +3 -0
  4. package/AGENTS.md +45 -0
  5. package/CHANGELOG.md +138 -0
  6. package/DEPRECATIONS.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +150 -0
  9. package/biome.json +41 -0
  10. package/convex/_generated/api.d.ts +153 -0
  11. package/convex/_generated/api.js +23 -0
  12. package/convex/_generated/dataModel.d.ts +60 -0
  13. package/convex/_generated/server.d.ts +143 -0
  14. package/convex/_generated/server.js +93 -0
  15. package/convex/auth.config.ts +8 -0
  16. package/convex/auth.ts +19 -0
  17. package/convex/comments.ts +88 -0
  18. package/convex/crons.ts +34 -0
  19. package/convex/devSeed.ts +459 -0
  20. package/convex/devSeedExtra.ts +541 -0
  21. package/convex/downloads.ts +78 -0
  22. package/convex/githubBackups.ts +170 -0
  23. package/convex/githubBackupsNode.ts +183 -0
  24. package/convex/githubImport.ts +317 -0
  25. package/convex/githubSoulBackups.ts +170 -0
  26. package/convex/githubSoulBackupsNode.ts +186 -0
  27. package/convex/http.ts +194 -0
  28. package/convex/httpApi.handlers.test.ts +488 -0
  29. package/convex/httpApi.test.ts +70 -0
  30. package/convex/httpApi.ts +305 -0
  31. package/convex/httpApiV1.handlers.test.ts +584 -0
  32. package/convex/httpApiV1.ts +1172 -0
  33. package/convex/leaderboards.ts +39 -0
  34. package/convex/lib/access.ts +36 -0
  35. package/convex/lib/apiTokenAuth.ts +36 -0
  36. package/convex/lib/badges.ts +50 -0
  37. package/convex/lib/changelog.test.ts +34 -0
  38. package/convex/lib/changelog.ts +278 -0
  39. package/convex/lib/embeddings.ts +38 -0
  40. package/convex/lib/githubBackup.ts +443 -0
  41. package/convex/lib/githubImport.test.ts +247 -0
  42. package/convex/lib/githubImport.ts +425 -0
  43. package/convex/lib/githubSoulBackup.ts +443 -0
  44. package/convex/lib/leaderboards.ts +103 -0
  45. package/convex/lib/moderation.ts +42 -0
  46. package/convex/lib/public.ts +89 -0
  47. package/convex/lib/searchText.test.ts +46 -0
  48. package/convex/lib/searchText.ts +27 -0
  49. package/convex/lib/skillBackfill.test.ts +34 -0
  50. package/convex/lib/skillBackfill.ts +67 -0
  51. package/convex/lib/skillPublish.test.ts +28 -0
  52. package/convex/lib/skillPublish.ts +284 -0
  53. package/convex/lib/skillStats.ts +80 -0
  54. package/convex/lib/skills.test.ts +197 -0
  55. package/convex/lib/skills.ts +273 -0
  56. package/convex/lib/soulChangelog.ts +273 -0
  57. package/convex/lib/soulPublish.ts +236 -0
  58. package/convex/lib/tokens.test.ts +33 -0
  59. package/convex/lib/tokens.ts +51 -0
  60. package/convex/lib/webhooks.test.ts +91 -0
  61. package/convex/lib/webhooks.ts +112 -0
  62. package/convex/maintenance.test.ts +270 -0
  63. package/convex/maintenance.ts +840 -0
  64. package/convex/rateLimits.ts +50 -0
  65. package/convex/schema.ts +472 -0
  66. package/convex/search.test.ts +12 -0
  67. package/convex/search.ts +254 -0
  68. package/convex/seed.test.ts +37 -0
  69. package/convex/seed.ts +254 -0
  70. package/convex/seedSouls.ts +111 -0
  71. package/convex/skillStatEvents.ts +568 -0
  72. package/convex/skills.ts +1606 -0
  73. package/convex/soulComments.ts +88 -0
  74. package/convex/soulDownloads.ts +14 -0
  75. package/convex/soulStars.ts +71 -0
  76. package/convex/souls.ts +570 -0
  77. package/convex/stars.ts +108 -0
  78. package/convex/statsMaintenance.ts +205 -0
  79. package/convex/telemetry.ts +434 -0
  80. package/convex/tokens.ts +88 -0
  81. package/convex/tsconfig.json +7 -0
  82. package/convex/uploads.ts +20 -0
  83. package/convex/users.ts +122 -0
  84. package/convex/webhooks.ts +50 -0
  85. package/convex.json +3 -0
  86. package/docs/README.md +32 -0
  87. package/docs/api.md +51 -0
  88. package/docs/architecture.md +61 -0
  89. package/docs/auth.md +54 -0
  90. package/docs/cli.md +117 -0
  91. package/docs/deploy.md +78 -0
  92. package/docs/diffing.md +84 -0
  93. package/docs/github-import.md +171 -0
  94. package/docs/http-api.md +187 -0
  95. package/docs/manual-testing.md +64 -0
  96. package/docs/mintlify.md +43 -0
  97. package/docs/quickstart.md +120 -0
  98. package/docs/skill-format.md +58 -0
  99. package/docs/soul-format.md +37 -0
  100. package/docs/spec.md +177 -0
  101. package/docs/telemetry.md +91 -0
  102. package/docs/troubleshooting.md +49 -0
  103. package/docs/webhook.md +51 -0
  104. package/e2e/menu-smoke.pw.test.ts +49 -0
  105. package/e2e/pilothub.e2e.test.ts +494 -0
  106. package/e2e/search-exact.pw.test.ts +97 -0
  107. package/package.json +84 -0
  108. package/packages/pilothub/LICENSE +22 -0
  109. package/packages/pilothub/README.md +57 -0
  110. package/packages/pilothub/bin/pilothub.js +2 -0
  111. package/packages/pilothub/package.json +41 -0
  112. package/packages/pilothub/src/browserAuth.test.ts +96 -0
  113. package/packages/pilothub/src/browserAuth.ts +174 -0
  114. package/packages/pilothub/src/cli/buildInfo.ts +94 -0
  115. package/packages/pilothub/src/cli/commands/auth.ts +97 -0
  116. package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
  117. package/packages/pilothub/src/cli/commands/delete.ts +83 -0
  118. package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
  119. package/packages/pilothub/src/cli/commands/publish.ts +108 -0
  120. package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
  121. package/packages/pilothub/src/cli/commands/skills.ts +380 -0
  122. package/packages/pilothub/src/cli/commands/star.ts +46 -0
  123. package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
  124. package/packages/pilothub/src/cli/commands/sync.ts +200 -0
  125. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
  126. package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
  127. package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
  128. package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
  129. package/packages/pilothub/src/cli/helpStyle.ts +45 -0
  130. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
  131. package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
  132. package/packages/pilothub/src/cli/registry.test.ts +63 -0
  133. package/packages/pilothub/src/cli/registry.ts +43 -0
  134. package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
  135. package/packages/pilothub/src/cli/scanSkills.ts +84 -0
  136. package/packages/pilothub/src/cli/slug.ts +16 -0
  137. package/packages/pilothub/src/cli/types.ts +12 -0
  138. package/packages/pilothub/src/cli/ui.ts +75 -0
  139. package/packages/pilothub/src/cli.ts +311 -0
  140. package/packages/pilothub/src/config.ts +36 -0
  141. package/packages/pilothub/src/discovery.test.ts +75 -0
  142. package/packages/pilothub/src/discovery.ts +19 -0
  143. package/packages/pilothub/src/http.test.ts +156 -0
  144. package/packages/pilothub/src/http.ts +301 -0
  145. package/packages/pilothub/src/schema/ark.ts +29 -0
  146. package/packages/pilothub/src/schema/index.ts +5 -0
  147. package/packages/pilothub/src/schema/routes.ts +22 -0
  148. package/packages/pilothub/src/schema/schemas.ts +260 -0
  149. package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
  150. package/packages/pilothub/src/schema/textFiles.ts +66 -0
  151. package/packages/pilothub/src/skills.test.ts +191 -0
  152. package/packages/pilothub/src/skills.ts +172 -0
  153. package/packages/pilothub/src/types.ts +10 -0
  154. package/packages/pilothub/tsconfig.json +14 -0
  155. package/packages/schema/README.md +3 -0
  156. package/packages/schema/dist/ark.d.ts +4 -0
  157. package/packages/schema/dist/ark.js +26 -0
  158. package/packages/schema/dist/ark.js.map +1 -0
  159. package/packages/schema/dist/index.d.ts +5 -0
  160. package/packages/schema/dist/index.js +5 -0
  161. package/packages/schema/dist/index.js.map +1 -0
  162. package/packages/schema/dist/routes.d.ts +21 -0
  163. package/packages/schema/dist/routes.js +22 -0
  164. package/packages/schema/dist/routes.js.map +1 -0
  165. package/packages/schema/dist/schemas.d.ts +297 -0
  166. package/packages/schema/dist/schemas.js +243 -0
  167. package/packages/schema/dist/schemas.js.map +1 -0
  168. package/packages/schema/dist/textFiles.d.ts +5 -0
  169. package/packages/schema/dist/textFiles.js +66 -0
  170. package/packages/schema/dist/textFiles.js.map +1 -0
  171. package/packages/schema/package.json +26 -0
  172. package/packages/schema/src/ark.ts +29 -0
  173. package/packages/schema/src/index.ts +5 -0
  174. package/packages/schema/src/routes.ts +22 -0
  175. package/packages/schema/src/schemas.test.ts +123 -0
  176. package/packages/schema/src/schemas.ts +287 -0
  177. package/packages/schema/src/textFiles.test.ts +23 -0
  178. package/packages/schema/src/textFiles.ts +66 -0
  179. package/packages/schema/tsconfig.json +15 -0
  180. package/pilothub +46 -0
  181. package/playwright.config.ts +33 -0
  182. package/public/.well-known/pilothub.json +6 -0
  183. package/public/api/v1/openapi.json +379 -0
  184. package/public/favicon.ico +0 -0
  185. package/public/logo192.png +0 -0
  186. package/public/logo512.png +0 -0
  187. package/public/manifest.json +25 -0
  188. package/public/og.png +0 -0
  189. package/public/og.svg +98 -0
  190. package/public/pilot-logo.png +0 -0
  191. package/public/pilot-mark.png +0 -0
  192. package/public/robots.txt +3 -0
  193. package/public/tanstack-circle-logo.png +0 -0
  194. package/public/tanstack-word-logo-white.svg +1 -0
  195. package/scripts/check-peer-deps.ts +56 -0
  196. package/scripts/docs-list.ts +148 -0
  197. package/scripts/run-playwright-local.sh +14 -0
  198. package/server/og/fetchSkillOgMeta.ts +27 -0
  199. package/server/og/fetchSoulOgMeta.ts +27 -0
  200. package/server/og/ogAssets.ts +80 -0
  201. package/server/og/skillOgSvg.test.ts +59 -0
  202. package/server/og/skillOgSvg.ts +258 -0
  203. package/server/og/soulOgSvg.ts +209 -0
  204. package/server/routes/og/skill.png.ts +103 -0
  205. package/server/routes/og/soul.png.ts +111 -0
  206. package/src/__tests__/skill-detail-page.test.tsx +86 -0
  207. package/src/__tests__/skills-index.test.tsx +145 -0
  208. package/src/__tests__/upload.route.test.tsx +228 -0
  209. package/src/components/AppProviders.tsx +19 -0
  210. package/src/components/ClientOnly.tsx +18 -0
  211. package/src/components/Footer.tsx +29 -0
  212. package/src/components/Header.tsx +295 -0
  213. package/src/components/InstallSwitcher.tsx +53 -0
  214. package/src/components/SkillCard.tsx +36 -0
  215. package/src/components/SkillDetailPage.tsx +817 -0
  216. package/src/components/SkillDiffCard.tsx +485 -0
  217. package/src/components/SoulCard.tsx +19 -0
  218. package/src/components/SoulDetailPage.tsx +263 -0
  219. package/src/components/UserBootstrap.tsx +18 -0
  220. package/src/components/ui/dropdown-menu.tsx +67 -0
  221. package/src/components/ui/toggle-group.tsx +35 -0
  222. package/src/convex/client.ts +3 -0
  223. package/src/lib/badges.ts +29 -0
  224. package/src/lib/diffing.test.ts +163 -0
  225. package/src/lib/diffing.ts +106 -0
  226. package/src/lib/gravatar.test.ts +9 -0
  227. package/src/lib/gravatar.ts +158 -0
  228. package/src/lib/og.test.ts +142 -0
  229. package/src/lib/og.ts +156 -0
  230. package/src/lib/publicUser.ts +39 -0
  231. package/src/lib/roles.ts +19 -0
  232. package/src/lib/site.test.ts +130 -0
  233. package/src/lib/site.ts +84 -0
  234. package/src/lib/theme-transition.test.ts +134 -0
  235. package/src/lib/theme-transition.ts +134 -0
  236. package/src/lib/theme.test.tsx +88 -0
  237. package/src/lib/theme.ts +43 -0
  238. package/src/lib/uploadFiles.jsdom.test.ts +33 -0
  239. package/src/lib/uploadFiles.test.ts +123 -0
  240. package/src/lib/uploadFiles.ts +245 -0
  241. package/src/lib/uploadUtils.test.ts +78 -0
  242. package/src/lib/uploadUtils.ts +93 -0
  243. package/src/lib/useAuthStatus.ts +12 -0
  244. package/src/lib/utils.test.ts +9 -0
  245. package/src/lib/utils.ts +6 -0
  246. package/src/logo.svg +12 -0
  247. package/src/routeTree.gen.ts +345 -0
  248. package/src/router.tsx +17 -0
  249. package/src/routes/$owner/$slug.tsx +55 -0
  250. package/src/routes/__root.tsx +136 -0
  251. package/src/routes/admin.tsx +11 -0
  252. package/src/routes/cli/auth.tsx +168 -0
  253. package/src/routes/dashboard.tsx +97 -0
  254. package/src/routes/import.tsx +415 -0
  255. package/src/routes/index.tsx +252 -0
  256. package/src/routes/management.tsx +529 -0
  257. package/src/routes/settings.tsx +203 -0
  258. package/src/routes/skills/index.tsx +422 -0
  259. package/src/routes/souls/$slug.tsx +55 -0
  260. package/src/routes/souls/index.tsx +243 -0
  261. package/src/routes/stars.tsx +68 -0
  262. package/src/routes/u/$handle.tsx +307 -0
  263. package/src/routes/upload/utils.ts +81 -0
  264. package/src/routes/upload.tsx +499 -0
  265. package/src/styles.css +2718 -0
  266. package/tsconfig.json +24 -0
  267. package/tsconfig.oxlint.json +16 -0
  268. package/vercel.json +8 -0
  269. package/vite.config.ts +48 -0
  270. package/vitest.config.ts +47 -0
  271. package/vitest.e2e.config.ts +11 -0
  272. package/vitest.setup.ts +1 -0
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Skill Stat Events - Event-sourced stats processing for skills
3
+ *
4
+ * Instead of updating skill stats synchronously in the hot path (which can cause
5
+ * contention when multiple users download/star/install the same skill), we insert
6
+ * lightweight event records and process them in batches via a cron job.
7
+ *
8
+ * Flow:
9
+ * 1. User action (download, star, install) → insertStatEvent() writes to skillStatEvents table
10
+ * 2. Cron job runs every 5 minutes → processSkillStatEventsInternal() processes batches
11
+ * 3. Events are aggregated per-skill to minimize database operations
12
+ * 4. Stats are applied to skill documents and daily stats tables
13
+ * 5. Events are marked as processed (kept forever for auditing)
14
+ */
15
+
16
+ import { v } from 'convex/values'
17
+ import { internal } from './_generated/api'
18
+ import type { Doc, Id } from './_generated/dataModel'
19
+ import type { MutationCtx } from './_generated/server'
20
+ import { internalAction, internalMutation, internalQuery } from './_generated/server'
21
+ import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
22
+
23
+ /**
24
+ * Event types that affect skill stats:
25
+ *
26
+ * - download: User downloaded skill as zip (+1 downloads)
27
+ * - star: User starred the skill (+1 stars)
28
+ * - unstar: User removed their star (-1 stars)
29
+ * - install_new: First time this user installed this skill (+1 installsAllTime, +1 installsCurrent)
30
+ * - install_reactivate: User re-added skill after removing it (+1 installsCurrent only)
31
+ * - install_deactivate: User removed skill from all projects (-1 installsCurrent)
32
+ * - install_clear: User cleared all telemetry data (custom delta for both allTime and current)
33
+ */
34
+ export type StatEventKind =
35
+ | 'download'
36
+ | 'star'
37
+ | 'unstar'
38
+ | 'install_new'
39
+ | 'install_reactivate'
40
+ | 'install_deactivate'
41
+ | 'install_clear'
42
+
43
+ /**
44
+ * Insert a stat event to be processed later by the cron job.
45
+ *
46
+ * This is called from the hot path (downloads, stars, telemetry) instead of
47
+ * directly updating skill stats. It's a single insert with no read-modify-write
48
+ * cycle, so it's fast and doesn't contend with other operations on the same skill.
49
+ *
50
+ * @param ctx - Mutation context
51
+ * @param params.skillId - The skill being affected
52
+ * @param params.kind - Type of event (download, star, install_new, etc.)
53
+ * @param params.occurredAt - When the event happened (defaults to now). Important for
54
+ * daily stats bucketing - we want downloads at 11:55 PM Monday
55
+ * to count toward Monday's stats even if processed on Tuesday.
56
+ * @param params.delta - Only used for install_clear events, specifies exact delta amounts
57
+ */
58
+ export async function insertStatEvent(
59
+ ctx: MutationCtx,
60
+ params: {
61
+ skillId: Id<'skills'>
62
+ kind: StatEventKind
63
+ occurredAt?: number
64
+ delta?: { allTime: number; current: number }
65
+ },
66
+ ) {
67
+ await ctx.db.insert('skillStatEvents', {
68
+ skillId: params.skillId,
69
+ kind: params.kind,
70
+ delta: params.delta,
71
+ occurredAt: params.occurredAt ?? Date.now(),
72
+ processedAt: undefined,
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Aggregated deltas for a single skill after processing multiple events.
78
+ *
79
+ * When we process a batch of 100 events, many might be for the same skill.
80
+ * Instead of updating the skill document once per event, we aggregate all
81
+ * events for each skill and apply a single update.
82
+ *
83
+ * The downloadEvents and installNewEvents arrays store the original timestamps
84
+ * so we can update daily stats with the correct day bucket for each event.
85
+ */
86
+ type AggregatedDeltas = {
87
+ downloads: number
88
+ stars: number
89
+ installsAllTime: number
90
+ installsCurrent: number
91
+ /** Original timestamps for each download event (for daily stats bucketing) */
92
+ downloadEvents: number[]
93
+ /** Original timestamps for each new install event (for daily stats bucketing) */
94
+ installNewEvents: number[]
95
+ }
96
+
97
+ /**
98
+ * Aggregate multiple events for a single skill into net deltas.
99
+ *
100
+ * Example: If a skill has these events in the batch:
101
+ * - download (Mon 11pm)
102
+ * - download (Tue 1am)
103
+ * - star
104
+ * - unstar
105
+ * - star
106
+ *
107
+ * The result would be:
108
+ * - downloads: 2
109
+ * - stars: 1 (net: +1 -1 +1 = +1)
110
+ * - downloadEvents: [<Mon 11pm timestamp>, <Tue 1am timestamp>]
111
+ *
112
+ * This aggregation reduces the number of database operations from N events
113
+ * to 1 skill update + N daily stat updates (which themselves may coalesce
114
+ * if multiple events fall on the same day).
115
+ */
116
+ function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas {
117
+ const result: AggregatedDeltas = {
118
+ downloads: 0,
119
+ stars: 0,
120
+ installsAllTime: 0,
121
+ installsCurrent: 0,
122
+ downloadEvents: [],
123
+ installNewEvents: [],
124
+ }
125
+
126
+ for (const event of events) {
127
+ switch (event.kind) {
128
+ case 'download':
129
+ result.downloads += 1
130
+ result.downloadEvents.push(event.occurredAt)
131
+ break
132
+ case 'star':
133
+ result.stars += 1
134
+ break
135
+ case 'unstar':
136
+ result.stars -= 1
137
+ break
138
+ case 'install_new':
139
+ // New user installing for the first time: count toward both lifetime and current
140
+ result.installsAllTime += 1
141
+ result.installsCurrent += 1
142
+ result.installNewEvents.push(event.occurredAt)
143
+ break
144
+ case 'install_reactivate':
145
+ // User re-added skill after removing: only affects current count
146
+ result.installsCurrent += 1
147
+ break
148
+ case 'install_deactivate':
149
+ // User removed skill from all projects: only affects current count
150
+ result.installsCurrent -= 1
151
+ break
152
+ case 'install_clear':
153
+ // User cleared telemetry: uses custom delta values (typically negative)
154
+ if (event.delta) {
155
+ result.installsAllTime += event.delta.allTime
156
+ result.installsCurrent += event.delta.current
157
+ }
158
+ break
159
+ }
160
+ }
161
+
162
+ return result
163
+ }
164
+
165
+ /**
166
+ * Process a batch of unprocessed stat events.
167
+ *
168
+ * Called by cron every 5 minutes. Processes up to batchSize events (default 100).
169
+ * If the batch is full, schedules an immediate follow-up run to drain the queue.
170
+ *
171
+ * Processing steps:
172
+ * 1. Query unprocessed events (processedAt is undefined)
173
+ * 2. Group events by skillId to minimize skill document fetches
174
+ * 3. For each skill:
175
+ * a. Fetch the skill document once
176
+ * b. Aggregate all events for this skill into net deltas
177
+ * c. Apply deltas to skill stats (downloads, stars, installs)
178
+ * d. Update daily stats for trending (using original event timestamps)
179
+ * e. Mark all events as processed
180
+ * 4. If batch was full, schedule another run immediately
181
+ *
182
+ * Aggregation levels:
183
+ * - Level 1: Batch of 100 events from the queue
184
+ * - Level 2: Group by skillId (e.g., 100 events → 30 unique skills)
185
+ * - Level 3: Aggregate events per skill (e.g., 5 events → 1 skill update)
186
+ * - Level 4: Daily stats may coalesce (e.g., 3 downloads same day → 1 upsert)
187
+ */
188
+ export const processSkillStatEventsInternal = internalMutation({
189
+ args: { batchSize: v.optional(v.number()) },
190
+ handler: async (ctx, args) => {
191
+ const batchSize = args.batchSize ?? 100
192
+ const now = Date.now()
193
+
194
+ // Level 1: Fetch a batch of unprocessed events
195
+ const events = await ctx.db
196
+ .query('skillStatEvents')
197
+ .withIndex('by_unprocessed', (q) => q.eq('processedAt', undefined))
198
+ .take(batchSize)
199
+
200
+ if (events.length === 0) {
201
+ return { processed: 0 }
202
+ }
203
+
204
+ // Level 2: Group events by skillId to minimize database reads
205
+ // Instead of fetching the same skill document multiple times,
206
+ // we fetch it once and process all its events together
207
+ const eventsBySkill = new Map<Id<'skills'>, Doc<'skillStatEvents'>[]>()
208
+ for (const event of events) {
209
+ const existing = eventsBySkill.get(event.skillId) ?? []
210
+ existing.push(event)
211
+ eventsBySkill.set(event.skillId, existing)
212
+ }
213
+
214
+ // Process each skill's events
215
+ for (const [skillId, skillEvents] of eventsBySkill) {
216
+ const skill = await ctx.db.get(skillId)
217
+
218
+ // Skill was deleted - just mark events as processed
219
+ if (!skill) {
220
+ for (const event of skillEvents) {
221
+ await ctx.db.patch(event._id, { processedAt: now })
222
+ }
223
+ continue
224
+ }
225
+
226
+ // Level 3: Aggregate all events for this skill into net deltas
227
+ // e.g., 3 downloads + 2 stars - 1 unstar → { downloads: 3, stars: 1 }
228
+ const deltas = aggregateEvents(skillEvents)
229
+
230
+ // Apply aggregated deltas to skill stats (single update per skill)
231
+ if (
232
+ deltas.downloads !== 0 ||
233
+ deltas.stars !== 0 ||
234
+ deltas.installsAllTime !== 0 ||
235
+ deltas.installsCurrent !== 0
236
+ ) {
237
+ const patch = applySkillStatDeltas(skill, {
238
+ downloads: deltas.downloads,
239
+ stars: deltas.stars,
240
+ installsAllTime: deltas.installsAllTime,
241
+ installsCurrent: deltas.installsCurrent,
242
+ })
243
+ await ctx.db.patch(skill._id, {
244
+ ...patch,
245
+ updatedAt: now,
246
+ })
247
+ }
248
+
249
+ // Update daily stats for trending/leaderboards
250
+ // We use the ORIGINAL event timestamp (occurredAt) so that:
251
+ // - A download at Mon 11:55 PM counts toward Monday's stats
252
+ // - Even if the cron processes it on Tuesday
253
+ //
254
+ // Level 4: bumpDailySkillStats does its own coalescing - multiple
255
+ // events on the same day will update the same daily record
256
+ for (const occurredAt of deltas.downloadEvents) {
257
+ await bumpDailySkillStats(ctx, { skillId, now: occurredAt, downloads: 1 })
258
+ }
259
+ for (const occurredAt of deltas.installNewEvents) {
260
+ await bumpDailySkillStats(ctx, { skillId, now: occurredAt, installs: 1 })
261
+ }
262
+
263
+ // Mark all events for this skill as processed
264
+ for (const event of skillEvents) {
265
+ await ctx.db.patch(event._id, { processedAt: now })
266
+ }
267
+ }
268
+
269
+ // If we hit the batch limit, there may be more events waiting.
270
+ // Schedule an immediate follow-up run to drain the queue.
271
+ // This ensures high-volume periods don't create a backlog.
272
+ if (events.length === batchSize) {
273
+ await ctx.scheduler.runAfter(0, internal.skillStatEvents.processSkillStatEventsInternal, {
274
+ batchSize,
275
+ })
276
+ }
277
+
278
+ return { processed: events.length }
279
+ },
280
+ })
281
+
282
+ // ============================================================================
283
+ // Action-based processing (cursor-based, runs outside transaction window)
284
+ // ============================================================================
285
+
286
+ const CURSOR_KEY = 'skill_stat_events'
287
+ const EVENT_BATCH_SIZE = 500
288
+ const MAX_SKILLS_PER_RUN = 500
289
+
290
+ /**
291
+ * Fetch a batch of events after the given cursor (by _creationTime).
292
+ * Returns events sorted by _creationTime ascending.
293
+ */
294
+ export const getUnprocessedEventBatch = internalQuery({
295
+ args: {
296
+ cursorCreationTime: v.optional(v.number()),
297
+ limit: v.optional(v.number()),
298
+ },
299
+ handler: async (ctx, args) => {
300
+ const limit = args.limit ?? EVENT_BATCH_SIZE
301
+ const cursor = args.cursorCreationTime
302
+
303
+ // Query events after the cursor using the built-in creation time index
304
+ const events = await ctx.db
305
+ .query('skillStatEvents')
306
+ .withIndex('by_creation_time', (q) =>
307
+ cursor !== undefined ? q.gt('_creationTime', cursor) : q,
308
+ )
309
+ .take(limit)
310
+ return events
311
+ },
312
+ })
313
+
314
+ /**
315
+ * Get the current cursor position from the cursors table.
316
+ */
317
+ export const getStatEventCursor = internalQuery({
318
+ args: {},
319
+ handler: async (ctx) => {
320
+ const cursor = await ctx.db
321
+ .query('skillStatUpdateCursors')
322
+ .withIndex('by_key', (q) => q.eq('key', CURSOR_KEY))
323
+ .unique()
324
+ return cursor?.cursorCreationTime
325
+ },
326
+ })
327
+
328
+ /**
329
+ * Validator for skill deltas passed to the mutation.
330
+ */
331
+ const skillDeltaValidator = v.object({
332
+ skillId: v.id('skills'),
333
+ downloads: v.number(),
334
+ stars: v.number(),
335
+ installsAllTime: v.number(),
336
+ installsCurrent: v.number(),
337
+ downloadEvents: v.array(v.number()),
338
+ installNewEvents: v.array(v.number()),
339
+ })
340
+
341
+ /**
342
+ * Apply aggregated stats to skills and update the cursor.
343
+ * This is a single atomic mutation that:
344
+ * 1. Updates all affected skills with their aggregated deltas
345
+ * 2. Updates daily stats for trending
346
+ * 3. Advances the cursor to the new position
347
+ */
348
+ export const applyAggregatedStatsAndUpdateCursor = internalMutation({
349
+ args: {
350
+ skillDeltas: v.array(skillDeltaValidator),
351
+ newCursor: v.number(),
352
+ },
353
+ handler: async (ctx, args) => {
354
+ const now = Date.now()
355
+
356
+ // Process each skill's aggregated deltas
357
+ for (const delta of args.skillDeltas) {
358
+ const skill = await ctx.db.get(delta.skillId)
359
+
360
+ // Skill was deleted - skip
361
+ if (!skill) {
362
+ continue
363
+ }
364
+
365
+ // Apply aggregated deltas to skill stats
366
+ if (
367
+ delta.downloads !== 0 ||
368
+ delta.stars !== 0 ||
369
+ delta.installsAllTime !== 0 ||
370
+ delta.installsCurrent !== 0
371
+ ) {
372
+ const patch = applySkillStatDeltas(skill, {
373
+ downloads: delta.downloads,
374
+ stars: delta.stars,
375
+ installsAllTime: delta.installsAllTime,
376
+ installsCurrent: delta.installsCurrent,
377
+ })
378
+ await ctx.db.patch(skill._id, {
379
+ ...patch,
380
+ updatedAt: now,
381
+ })
382
+ }
383
+
384
+ // Update daily stats for trending/leaderboards
385
+ for (const occurredAt of delta.downloadEvents) {
386
+ await bumpDailySkillStats(ctx, { skillId: delta.skillId, now: occurredAt, downloads: 1 })
387
+ }
388
+ for (const occurredAt of delta.installNewEvents) {
389
+ await bumpDailySkillStats(ctx, { skillId: delta.skillId, now: occurredAt, installs: 1 })
390
+ }
391
+ }
392
+
393
+ // Update cursor position (upsert)
394
+ const existingCursor = await ctx.db
395
+ .query('skillStatUpdateCursors')
396
+ .withIndex('by_key', (q) => q.eq('key', CURSOR_KEY))
397
+ .unique()
398
+
399
+ if (existingCursor) {
400
+ await ctx.db.patch(existingCursor._id, {
401
+ cursorCreationTime: args.newCursor,
402
+ updatedAt: now,
403
+ })
404
+ } else {
405
+ await ctx.db.insert('skillStatUpdateCursors', {
406
+ key: CURSOR_KEY,
407
+ cursorCreationTime: args.newCursor,
408
+ updatedAt: now,
409
+ })
410
+ }
411
+
412
+ return { skillsUpdated: args.skillDeltas.length }
413
+ },
414
+ })
415
+
416
+ /**
417
+ * Action that processes skill stat events in batches outside the transaction window.
418
+ *
419
+ * Algorithm:
420
+ * 1. Get current cursor position
421
+ * 2. Fetch events in batches of 500, aggregating as we go
422
+ * 3. Stop when we have >= 500 unique skills OR run out of events
423
+ * 4. Call mutation to apply all deltas and update cursor atomically
424
+ * 5. Self-schedule if we stopped due to skill limit (not exhaustion)
425
+ */
426
+ export const processSkillStatEventsAction = internalAction({
427
+ args: {},
428
+ handler: async (ctx) => {
429
+ // Get current cursor position (convert null to undefined for consistency)
430
+ const cursorResult = await ctx.runQuery(internal.skillStatEvents.getStatEventCursor)
431
+ let cursor: number | undefined = cursorResult ?? undefined
432
+
433
+ console.log(`[STAT-AGG] Starting aggregation, cursor=${cursor ?? 'none'}`)
434
+
435
+ // Aggregated deltas per skill
436
+ const aggregatedBySkill = new Map<
437
+ Id<'skills'>,
438
+ {
439
+ downloads: number
440
+ stars: number
441
+ installsAllTime: number
442
+ installsCurrent: number
443
+ downloadEvents: number[]
444
+ installNewEvents: number[]
445
+ }
446
+ >()
447
+
448
+ let maxCreationTime: number | undefined = cursor
449
+ let exhausted = false
450
+ let totalEventsFetched = 0
451
+
452
+ // Fetch and aggregate until we have enough skills or run out of events
453
+ while (aggregatedBySkill.size < MAX_SKILLS_PER_RUN) {
454
+ const events = await ctx.runQuery(internal.skillStatEvents.getUnprocessedEventBatch, {
455
+ cursorCreationTime: cursor,
456
+ limit: EVENT_BATCH_SIZE,
457
+ })
458
+
459
+ if (events.length === 0) {
460
+ exhausted = true
461
+ break
462
+ }
463
+
464
+ totalEventsFetched += events.length
465
+ const skillsBefore = aggregatedBySkill.size
466
+
467
+ // Aggregate events into per-skill deltas
468
+ for (const event of events) {
469
+ let skillDelta = aggregatedBySkill.get(event.skillId)
470
+ if (!skillDelta) {
471
+ skillDelta = {
472
+ downloads: 0,
473
+ stars: 0,
474
+ installsAllTime: 0,
475
+ installsCurrent: 0,
476
+ downloadEvents: [],
477
+ installNewEvents: [],
478
+ }
479
+ aggregatedBySkill.set(event.skillId, skillDelta)
480
+ }
481
+
482
+ // Apply event to aggregated deltas
483
+ switch (event.kind) {
484
+ case 'download':
485
+ skillDelta.downloads += 1
486
+ skillDelta.downloadEvents.push(event.occurredAt)
487
+ break
488
+ case 'star':
489
+ skillDelta.stars += 1
490
+ break
491
+ case 'unstar':
492
+ skillDelta.stars -= 1
493
+ break
494
+ case 'install_new':
495
+ skillDelta.installsAllTime += 1
496
+ skillDelta.installsCurrent += 1
497
+ skillDelta.installNewEvents.push(event.occurredAt)
498
+ break
499
+ case 'install_reactivate':
500
+ skillDelta.installsCurrent += 1
501
+ break
502
+ case 'install_deactivate':
503
+ skillDelta.installsCurrent -= 1
504
+ break
505
+ case 'install_clear':
506
+ if (event.delta) {
507
+ skillDelta.installsAllTime += event.delta.allTime
508
+ skillDelta.installsCurrent += event.delta.current
509
+ }
510
+ break
511
+ }
512
+
513
+ // Track highest _creationTime seen
514
+ if (maxCreationTime === undefined || event._creationTime > maxCreationTime) {
515
+ maxCreationTime = event._creationTime
516
+ }
517
+ }
518
+
519
+ // Update cursor for next batch fetch
520
+ cursor = events[events.length - 1]._creationTime
521
+
522
+ console.log(
523
+ `[STAT-AGG] Fetched ${events.length} events, ${aggregatedBySkill.size - skillsBefore} new skills (${aggregatedBySkill.size} total)`,
524
+ )
525
+
526
+ // If we got fewer than requested, we've exhausted the events
527
+ if (events.length < EVENT_BATCH_SIZE) {
528
+ exhausted = true
529
+ break
530
+ }
531
+ }
532
+
533
+ // If we have nothing to process, we're done
534
+ if (aggregatedBySkill.size === 0 || maxCreationTime === undefined) {
535
+ console.log('[STAT-AGG] No events to process, done')
536
+ return { processed: 0, skillsUpdated: 0, exhausted: true }
537
+ }
538
+
539
+ // Convert map to array for mutation
540
+ const skillDeltas = Array.from(aggregatedBySkill.entries()).map(([skillId, delta]) => ({
541
+ skillId,
542
+ ...delta,
543
+ }))
544
+
545
+ console.log(
546
+ `[STAT-AGG] Running mutation for ${skillDeltas.length} skills (${totalEventsFetched} total events)`,
547
+ )
548
+
549
+ // Apply all deltas and update cursor atomically
550
+ await ctx.runMutation(internal.skillStatEvents.applyAggregatedStatsAndUpdateCursor, {
551
+ skillDeltas,
552
+ newCursor: maxCreationTime,
553
+ })
554
+
555
+ // Self-schedule if we stopped because of skill limit, not exhaustion
556
+ if (!exhausted) {
557
+ console.log('[STAT-AGG] More events remaining, self-scheduling')
558
+ await ctx.scheduler.runAfter(0, internal.skillStatEvents.processSkillStatEventsAction, {})
559
+ } else {
560
+ console.log('[STAT-AGG] All events processed, done')
561
+ }
562
+
563
+ return {
564
+ skillsUpdated: skillDeltas.length,
565
+ exhausted,
566
+ }
567
+ },
568
+ })