pilothub 0.0.1 → 0.0.2

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 (388) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +36 -129
  3. package/dist/browserAuth.d.ts +20 -0
  4. package/dist/browserAuth.js +156 -0
  5. package/dist/browserAuth.js.map +1 -0
  6. package/dist/browserAuth.test.d.ts +1 -0
  7. package/dist/browserAuth.test.js +83 -0
  8. package/dist/browserAuth.test.js.map +1 -0
  9. package/dist/cli/buildInfo.d.ts +3 -0
  10. package/dist/cli/buildInfo.js +103 -0
  11. package/dist/cli/buildInfo.js.map +1 -0
  12. package/dist/cli/commands/auth.d.ts +9 -0
  13. package/dist/cli/commands/auth.js +75 -0
  14. package/dist/cli/commands/auth.js.map +1 -0
  15. package/dist/cli/commands/delete.d.ts +11 -0
  16. package/dist/cli/commands/delete.js +67 -0
  17. package/dist/cli/commands/delete.js.map +1 -0
  18. package/dist/cli/commands/delete.test.d.ts +1 -0
  19. package/dist/cli/commands/delete.test.js +52 -0
  20. package/dist/cli/commands/delete.test.js.map +1 -0
  21. package/dist/cli/commands/publish.d.ts +9 -0
  22. package/dist/cli/commands/publish.js +87 -0
  23. package/dist/cli/commands/publish.js.map +1 -0
  24. package/dist/cli/commands/publish.test.d.ts +1 -0
  25. package/dist/cli/commands/publish.test.js +104 -0
  26. package/dist/cli/commands/publish.test.js.map +1 -0
  27. package/dist/cli/commands/skills.d.ts +23 -0
  28. package/dist/cli/commands/skills.js +298 -0
  29. package/dist/cli/commands/skills.js.map +1 -0
  30. package/dist/cli/commands/skills.test.d.ts +1 -0
  31. package/dist/cli/commands/skills.test.js +156 -0
  32. package/dist/cli/commands/skills.test.js.map +1 -0
  33. package/dist/cli/commands/star.d.ts +8 -0
  34. package/dist/cli/commands/star.js +38 -0
  35. package/dist/cli/commands/star.js.map +1 -0
  36. package/dist/cli/commands/sync.d.ts +3 -0
  37. package/dist/cli/commands/sync.js +160 -0
  38. package/dist/cli/commands/sync.js.map +1 -0
  39. package/dist/cli/commands/sync.test.d.ts +1 -0
  40. package/dist/cli/commands/sync.test.js +277 -0
  41. package/dist/cli/commands/sync.test.js.map +1 -0
  42. package/dist/cli/commands/syncHelpers.d.ts +76 -0
  43. package/dist/cli/commands/syncHelpers.js +349 -0
  44. package/dist/cli/commands/syncHelpers.js.map +1 -0
  45. package/dist/cli/commands/syncHelpers.test.d.ts +1 -0
  46. package/dist/cli/commands/syncHelpers.test.js +22 -0
  47. package/dist/cli/commands/syncHelpers.test.js.map +1 -0
  48. package/dist/cli/commands/syncTypes.d.ts +24 -0
  49. package/dist/cli/commands/syncTypes.js +2 -0
  50. package/dist/cli/commands/syncTypes.js.map +1 -0
  51. package/dist/cli/commands/unstar.d.ts +8 -0
  52. package/dist/cli/commands/unstar.js +38 -0
  53. package/dist/cli/commands/unstar.js.map +1 -0
  54. package/dist/cli/helpStyle.d.ts +13 -0
  55. package/dist/cli/helpStyle.js +38 -0
  56. package/dist/cli/helpStyle.js.map +1 -0
  57. package/dist/cli/pilotbotConfig.d.ts +6 -0
  58. package/dist/cli/pilotbotConfig.js +110 -0
  59. package/dist/cli/pilotbotConfig.js.map +1 -0
  60. package/dist/cli/pilotbotConfig.test.d.ts +1 -0
  61. package/dist/cli/pilotbotConfig.test.js +133 -0
  62. package/dist/cli/pilotbotConfig.test.js.map +1 -0
  63. package/dist/cli/registry.d.ts +7 -0
  64. package/dist/cli/registry.js +42 -0
  65. package/dist/cli/registry.js.map +1 -0
  66. package/dist/cli/registry.test.d.ts +1 -0
  67. package/dist/cli/registry.test.js +48 -0
  68. package/dist/cli/registry.test.js.map +1 -0
  69. package/dist/cli/scanSkills.d.ts +7 -0
  70. package/dist/cli/scanSkills.js +75 -0
  71. package/dist/cli/scanSkills.js.map +1 -0
  72. package/dist/cli/scanSkills.test.d.ts +1 -0
  73. package/dist/cli/scanSkills.test.js +60 -0
  74. package/dist/cli/scanSkills.test.js.map +1 -0
  75. package/dist/cli/slug.d.ts +2 -0
  76. package/dist/cli/slug.js +16 -0
  77. package/dist/cli/slug.js.map +1 -0
  78. package/dist/cli/types.d.ts +15 -0
  79. package/dist/cli/types.js +2 -0
  80. package/dist/cli/types.js.map +1 -0
  81. package/dist/cli/ui.d.ts +7 -0
  82. package/dist/cli/ui.js +72 -0
  83. package/dist/cli/ui.js.map +1 -0
  84. package/dist/cli.d.ts +2 -0
  85. package/dist/cli.js +268 -0
  86. package/dist/cli.js.map +1 -0
  87. package/dist/config.d.ts +4 -0
  88. package/dist/config.js +38 -0
  89. package/dist/config.js.map +1 -0
  90. package/dist/discovery.d.ts +5 -0
  91. package/dist/discovery.js +21 -0
  92. package/dist/discovery.js.map +1 -0
  93. package/dist/discovery.test.d.ts +1 -0
  94. package/dist/discovery.test.js +46 -0
  95. package/dist/discovery.test.js.map +1 -0
  96. package/dist/http.d.ts +32 -0
  97. package/dist/http.js +261 -0
  98. package/dist/http.js.map +1 -0
  99. package/dist/http.test.d.ts +1 -0
  100. package/dist/http.test.js +135 -0
  101. package/dist/http.test.js.map +1 -0
  102. package/dist/schema/ark.js.map +1 -0
  103. package/dist/schema/index.js.map +1 -0
  104. package/dist/schema/routes.js.map +1 -0
  105. package/{packages/schema/dist → dist/schema}/schemas.d.ts +0 -39
  106. package/{packages/schema/dist → dist/schema}/schemas.js +0 -22
  107. package/dist/schema/schemas.js.map +1 -0
  108. package/dist/schema/textFiles.js.map +1 -0
  109. package/dist/schema/textFiles.test.d.ts +1 -0
  110. package/dist/schema/textFiles.test.js +20 -0
  111. package/dist/schema/textFiles.test.js.map +1 -0
  112. package/dist/skills.d.ts +43 -0
  113. package/dist/skills.js +163 -0
  114. package/dist/skills.js.map +1 -0
  115. package/dist/skills.test.d.ts +1 -0
  116. package/dist/skills.test.js +144 -0
  117. package/dist/skills.test.js.map +1 -0
  118. package/dist/types.d.ts +7 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/package.json +27 -70
  122. package/.env.local.example +0 -19
  123. package/.github/workflows/ci.yml +0 -40
  124. package/.oxlintrc.json +0 -3
  125. package/AGENTS.md +0 -45
  126. package/CHANGELOG.md +0 -138
  127. package/DEPRECATIONS.md +0 -7
  128. package/biome.json +0 -41
  129. package/convex/_generated/api.d.ts +0 -153
  130. package/convex/_generated/api.js +0 -23
  131. package/convex/_generated/dataModel.d.ts +0 -60
  132. package/convex/_generated/server.d.ts +0 -143
  133. package/convex/_generated/server.js +0 -93
  134. package/convex/auth.config.ts +0 -8
  135. package/convex/auth.ts +0 -19
  136. package/convex/comments.ts +0 -88
  137. package/convex/crons.ts +0 -34
  138. package/convex/devSeed.ts +0 -459
  139. package/convex/devSeedExtra.ts +0 -541
  140. package/convex/downloads.ts +0 -78
  141. package/convex/githubBackups.ts +0 -170
  142. package/convex/githubBackupsNode.ts +0 -183
  143. package/convex/githubImport.ts +0 -317
  144. package/convex/githubSoulBackups.ts +0 -170
  145. package/convex/githubSoulBackupsNode.ts +0 -186
  146. package/convex/http.ts +0 -194
  147. package/convex/httpApi.handlers.test.ts +0 -488
  148. package/convex/httpApi.test.ts +0 -70
  149. package/convex/httpApi.ts +0 -305
  150. package/convex/httpApiV1.handlers.test.ts +0 -584
  151. package/convex/httpApiV1.ts +0 -1172
  152. package/convex/leaderboards.ts +0 -39
  153. package/convex/lib/access.ts +0 -36
  154. package/convex/lib/apiTokenAuth.ts +0 -36
  155. package/convex/lib/badges.ts +0 -50
  156. package/convex/lib/changelog.test.ts +0 -34
  157. package/convex/lib/changelog.ts +0 -278
  158. package/convex/lib/embeddings.ts +0 -38
  159. package/convex/lib/githubBackup.ts +0 -443
  160. package/convex/lib/githubImport.test.ts +0 -247
  161. package/convex/lib/githubImport.ts +0 -425
  162. package/convex/lib/githubSoulBackup.ts +0 -443
  163. package/convex/lib/leaderboards.ts +0 -103
  164. package/convex/lib/moderation.ts +0 -42
  165. package/convex/lib/public.ts +0 -89
  166. package/convex/lib/searchText.test.ts +0 -46
  167. package/convex/lib/searchText.ts +0 -27
  168. package/convex/lib/skillBackfill.test.ts +0 -34
  169. package/convex/lib/skillBackfill.ts +0 -67
  170. package/convex/lib/skillPublish.test.ts +0 -28
  171. package/convex/lib/skillPublish.ts +0 -284
  172. package/convex/lib/skillStats.ts +0 -80
  173. package/convex/lib/skills.test.ts +0 -197
  174. package/convex/lib/skills.ts +0 -273
  175. package/convex/lib/soulChangelog.ts +0 -273
  176. package/convex/lib/soulPublish.ts +0 -236
  177. package/convex/lib/tokens.test.ts +0 -33
  178. package/convex/lib/tokens.ts +0 -51
  179. package/convex/lib/webhooks.test.ts +0 -91
  180. package/convex/lib/webhooks.ts +0 -112
  181. package/convex/maintenance.test.ts +0 -270
  182. package/convex/maintenance.ts +0 -840
  183. package/convex/rateLimits.ts +0 -50
  184. package/convex/schema.ts +0 -472
  185. package/convex/search.test.ts +0 -12
  186. package/convex/search.ts +0 -254
  187. package/convex/seed.test.ts +0 -37
  188. package/convex/seed.ts +0 -254
  189. package/convex/seedSouls.ts +0 -111
  190. package/convex/skillStatEvents.ts +0 -568
  191. package/convex/skills.ts +0 -1606
  192. package/convex/soulComments.ts +0 -88
  193. package/convex/soulDownloads.ts +0 -14
  194. package/convex/soulStars.ts +0 -71
  195. package/convex/souls.ts +0 -570
  196. package/convex/stars.ts +0 -108
  197. package/convex/statsMaintenance.ts +0 -205
  198. package/convex/telemetry.ts +0 -434
  199. package/convex/tokens.ts +0 -88
  200. package/convex/tsconfig.json +0 -7
  201. package/convex/uploads.ts +0 -20
  202. package/convex/users.ts +0 -122
  203. package/convex/webhooks.ts +0 -50
  204. package/convex.json +0 -3
  205. package/docs/README.md +0 -32
  206. package/docs/api.md +0 -51
  207. package/docs/architecture.md +0 -61
  208. package/docs/auth.md +0 -54
  209. package/docs/cli.md +0 -117
  210. package/docs/deploy.md +0 -78
  211. package/docs/diffing.md +0 -84
  212. package/docs/github-import.md +0 -171
  213. package/docs/http-api.md +0 -187
  214. package/docs/manual-testing.md +0 -64
  215. package/docs/mintlify.md +0 -43
  216. package/docs/quickstart.md +0 -120
  217. package/docs/skill-format.md +0 -58
  218. package/docs/soul-format.md +0 -37
  219. package/docs/spec.md +0 -177
  220. package/docs/telemetry.md +0 -91
  221. package/docs/troubleshooting.md +0 -49
  222. package/docs/webhook.md +0 -51
  223. package/e2e/menu-smoke.pw.test.ts +0 -49
  224. package/e2e/pilothub.e2e.test.ts +0 -494
  225. package/e2e/search-exact.pw.test.ts +0 -97
  226. package/packages/pilothub/LICENSE +0 -22
  227. package/packages/pilothub/README.md +0 -57
  228. package/packages/pilothub/package.json +0 -41
  229. package/packages/pilothub/src/browserAuth.test.ts +0 -96
  230. package/packages/pilothub/src/browserAuth.ts +0 -174
  231. package/packages/pilothub/src/cli/buildInfo.ts +0 -94
  232. package/packages/pilothub/src/cli/commands/auth.ts +0 -97
  233. package/packages/pilothub/src/cli/commands/delete.test.ts +0 -73
  234. package/packages/pilothub/src/cli/commands/delete.ts +0 -83
  235. package/packages/pilothub/src/cli/commands/publish.test.ts +0 -122
  236. package/packages/pilothub/src/cli/commands/publish.ts +0 -108
  237. package/packages/pilothub/src/cli/commands/skills.test.ts +0 -191
  238. package/packages/pilothub/src/cli/commands/skills.ts +0 -380
  239. package/packages/pilothub/src/cli/commands/star.ts +0 -46
  240. package/packages/pilothub/src/cli/commands/sync.test.ts +0 -310
  241. package/packages/pilothub/src/cli/commands/sync.ts +0 -200
  242. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +0 -26
  243. package/packages/pilothub/src/cli/commands/syncHelpers.ts +0 -427
  244. package/packages/pilothub/src/cli/commands/syncTypes.ts +0 -27
  245. package/packages/pilothub/src/cli/commands/unstar.ts +0 -48
  246. package/packages/pilothub/src/cli/helpStyle.ts +0 -45
  247. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +0 -159
  248. package/packages/pilothub/src/cli/pilotbotConfig.ts +0 -147
  249. package/packages/pilothub/src/cli/registry.test.ts +0 -63
  250. package/packages/pilothub/src/cli/registry.ts +0 -43
  251. package/packages/pilothub/src/cli/scanSkills.test.ts +0 -64
  252. package/packages/pilothub/src/cli/scanSkills.ts +0 -84
  253. package/packages/pilothub/src/cli/slug.ts +0 -16
  254. package/packages/pilothub/src/cli/types.ts +0 -12
  255. package/packages/pilothub/src/cli/ui.ts +0 -75
  256. package/packages/pilothub/src/cli.ts +0 -311
  257. package/packages/pilothub/src/config.ts +0 -36
  258. package/packages/pilothub/src/discovery.test.ts +0 -75
  259. package/packages/pilothub/src/discovery.ts +0 -19
  260. package/packages/pilothub/src/http.test.ts +0 -156
  261. package/packages/pilothub/src/http.ts +0 -301
  262. package/packages/pilothub/src/schema/ark.ts +0 -29
  263. package/packages/pilothub/src/schema/index.ts +0 -5
  264. package/packages/pilothub/src/schema/routes.ts +0 -22
  265. package/packages/pilothub/src/schema/schemas.ts +0 -260
  266. package/packages/pilothub/src/schema/textFiles.test.ts +0 -23
  267. package/packages/pilothub/src/schema/textFiles.ts +0 -66
  268. package/packages/pilothub/src/skills.test.ts +0 -191
  269. package/packages/pilothub/src/skills.ts +0 -172
  270. package/packages/pilothub/src/types.ts +0 -10
  271. package/packages/pilothub/tsconfig.json +0 -14
  272. package/packages/schema/README.md +0 -3
  273. package/packages/schema/dist/ark.js.map +0 -1
  274. package/packages/schema/dist/index.js.map +0 -1
  275. package/packages/schema/dist/routes.js.map +0 -1
  276. package/packages/schema/dist/schemas.js.map +0 -1
  277. package/packages/schema/dist/textFiles.js.map +0 -1
  278. package/packages/schema/package.json +0 -26
  279. package/packages/schema/src/ark.ts +0 -29
  280. package/packages/schema/src/index.ts +0 -5
  281. package/packages/schema/src/routes.ts +0 -22
  282. package/packages/schema/src/schemas.test.ts +0 -123
  283. package/packages/schema/src/schemas.ts +0 -287
  284. package/packages/schema/src/textFiles.test.ts +0 -23
  285. package/packages/schema/src/textFiles.ts +0 -66
  286. package/packages/schema/tsconfig.json +0 -15
  287. package/pilothub +0 -46
  288. package/playwright.config.ts +0 -33
  289. package/public/.well-known/pilothub.json +0 -6
  290. package/public/api/v1/openapi.json +0 -379
  291. package/public/favicon.ico +0 -0
  292. package/public/logo192.png +0 -0
  293. package/public/logo512.png +0 -0
  294. package/public/manifest.json +0 -25
  295. package/public/og.png +0 -0
  296. package/public/og.svg +0 -98
  297. package/public/pilot-logo.png +0 -0
  298. package/public/pilot-mark.png +0 -0
  299. package/public/robots.txt +0 -3
  300. package/public/tanstack-circle-logo.png +0 -0
  301. package/public/tanstack-word-logo-white.svg +0 -1
  302. package/scripts/check-peer-deps.ts +0 -56
  303. package/scripts/docs-list.ts +0 -148
  304. package/scripts/run-playwright-local.sh +0 -14
  305. package/server/og/fetchSkillOgMeta.ts +0 -27
  306. package/server/og/fetchSoulOgMeta.ts +0 -27
  307. package/server/og/ogAssets.ts +0 -80
  308. package/server/og/skillOgSvg.test.ts +0 -59
  309. package/server/og/skillOgSvg.ts +0 -258
  310. package/server/og/soulOgSvg.ts +0 -209
  311. package/server/routes/og/skill.png.ts +0 -103
  312. package/server/routes/og/soul.png.ts +0 -111
  313. package/src/__tests__/skill-detail-page.test.tsx +0 -86
  314. package/src/__tests__/skills-index.test.tsx +0 -145
  315. package/src/__tests__/upload.route.test.tsx +0 -228
  316. package/src/components/AppProviders.tsx +0 -19
  317. package/src/components/ClientOnly.tsx +0 -18
  318. package/src/components/Footer.tsx +0 -29
  319. package/src/components/Header.tsx +0 -295
  320. package/src/components/InstallSwitcher.tsx +0 -53
  321. package/src/components/SkillCard.tsx +0 -36
  322. package/src/components/SkillDetailPage.tsx +0 -817
  323. package/src/components/SkillDiffCard.tsx +0 -485
  324. package/src/components/SoulCard.tsx +0 -19
  325. package/src/components/SoulDetailPage.tsx +0 -263
  326. package/src/components/UserBootstrap.tsx +0 -18
  327. package/src/components/ui/dropdown-menu.tsx +0 -67
  328. package/src/components/ui/toggle-group.tsx +0 -35
  329. package/src/convex/client.ts +0 -3
  330. package/src/lib/badges.ts +0 -29
  331. package/src/lib/diffing.test.ts +0 -163
  332. package/src/lib/diffing.ts +0 -106
  333. package/src/lib/gravatar.test.ts +0 -9
  334. package/src/lib/gravatar.ts +0 -158
  335. package/src/lib/og.test.ts +0 -142
  336. package/src/lib/og.ts +0 -156
  337. package/src/lib/publicUser.ts +0 -39
  338. package/src/lib/roles.ts +0 -19
  339. package/src/lib/site.test.ts +0 -130
  340. package/src/lib/site.ts +0 -84
  341. package/src/lib/theme-transition.test.ts +0 -134
  342. package/src/lib/theme-transition.ts +0 -134
  343. package/src/lib/theme.test.tsx +0 -88
  344. package/src/lib/theme.ts +0 -43
  345. package/src/lib/uploadFiles.jsdom.test.ts +0 -33
  346. package/src/lib/uploadFiles.test.ts +0 -123
  347. package/src/lib/uploadFiles.ts +0 -245
  348. package/src/lib/uploadUtils.test.ts +0 -78
  349. package/src/lib/uploadUtils.ts +0 -93
  350. package/src/lib/useAuthStatus.ts +0 -12
  351. package/src/lib/utils.test.ts +0 -9
  352. package/src/lib/utils.ts +0 -6
  353. package/src/logo.svg +0 -12
  354. package/src/routeTree.gen.ts +0 -345
  355. package/src/router.tsx +0 -17
  356. package/src/routes/$owner/$slug.tsx +0 -55
  357. package/src/routes/__root.tsx +0 -136
  358. package/src/routes/admin.tsx +0 -11
  359. package/src/routes/cli/auth.tsx +0 -168
  360. package/src/routes/dashboard.tsx +0 -97
  361. package/src/routes/import.tsx +0 -415
  362. package/src/routes/index.tsx +0 -252
  363. package/src/routes/management.tsx +0 -529
  364. package/src/routes/settings.tsx +0 -203
  365. package/src/routes/skills/index.tsx +0 -422
  366. package/src/routes/souls/$slug.tsx +0 -55
  367. package/src/routes/souls/index.tsx +0 -243
  368. package/src/routes/stars.tsx +0 -68
  369. package/src/routes/u/$handle.tsx +0 -307
  370. package/src/routes/upload/utils.ts +0 -81
  371. package/src/routes/upload.tsx +0 -499
  372. package/src/styles.css +0 -2718
  373. package/tsconfig.json +0 -24
  374. package/tsconfig.oxlint.json +0 -16
  375. package/vercel.json +0 -8
  376. package/vite.config.ts +0 -48
  377. package/vitest.config.ts +0 -47
  378. package/vitest.e2e.config.ts +0 -11
  379. package/vitest.setup.ts +0 -1
  380. /package/{packages/pilothub/bin → bin}/pilothub.js +0 -0
  381. /package/{packages/schema/dist → dist/schema}/ark.d.ts +0 -0
  382. /package/{packages/schema/dist → dist/schema}/ark.js +0 -0
  383. /package/{packages/schema/dist → dist/schema}/index.d.ts +0 -0
  384. /package/{packages/schema/dist → dist/schema}/index.js +0 -0
  385. /package/{packages/schema/dist → dist/schema}/routes.d.ts +0 -0
  386. /package/{packages/schema/dist → dist/schema}/routes.js +0 -0
  387. /package/{packages/schema/dist → dist/schema}/textFiles.d.ts +0 -0
  388. /package/{packages/schema/dist → dist/schema}/textFiles.js +0 -0
package/convex/skills.ts DELETED
@@ -1,1606 +0,0 @@
1
- import { paginationOptsValidator } from 'convex/server'
2
- import { ConvexError, v } from 'convex/values'
3
- import { paginator } from 'convex-helpers/server/pagination'
4
- import { internal } from './_generated/api'
5
- import type { Doc, Id } from './_generated/dataModel'
6
- import type { MutationCtx, QueryCtx } from './_generated/server'
7
- import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
8
- import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from './lib/access'
9
- import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from './lib/badges'
10
- import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog'
11
- import { buildTrendingLeaderboard } from './lib/leaderboards'
12
- import { deriveModerationFlags } from './lib/moderation'
13
- import { toPublicSkill, toPublicUser } from './lib/public'
14
- import {
15
- fetchText,
16
- type PublishResult,
17
- publishVersionForUser,
18
- queueHighlightedWebhook,
19
- } from './lib/skillPublish'
20
- import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
21
- import schema from './schema'
22
-
23
- export { publishVersionForUser } from './lib/skillPublish'
24
-
25
- type ReadmeResult = { path: string; text: string }
26
- type FileTextResult = { path: string; text: string; size: number; sha256: string }
27
-
28
- const MAX_DIFF_FILE_BYTES = 200 * 1024
29
- const MAX_LIST_LIMIT = 50
30
- const MAX_PUBLIC_LIST_LIMIT = 200
31
- const MAX_LIST_BULK_LIMIT = 200
32
- const MAX_LIST_TAKE = 1000
33
-
34
- async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
35
- const owner = await ctx.db.get(ownerUserId)
36
- return owner?.handle ?? owner?._id ?? null
37
- }
38
-
39
- type PublicSkillEntry = {
40
- skill: NonNullable<ReturnType<typeof toPublicSkill>>
41
- latestVersion: Doc<'skillVersions'> | null
42
- ownerHandle: string | null
43
- }
44
-
45
- type ManagementSkillEntry = {
46
- skill: Doc<'skills'>
47
- latestVersion: Doc<'skillVersions'> | null
48
- owner: Doc<'users'> | null
49
- }
50
-
51
- type BadgeKind = Doc<'skillBadges'>['kind']
52
-
53
- async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
54
- const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
55
- const badgeMapBySkillId = await getSkillBadgeMaps(
56
- ctx,
57
- skills.map((skill) => skill._id),
58
- )
59
-
60
- const getOwnerHandle = (ownerUserId: Id<'users'>) => {
61
- const cached = ownerHandleCache.get(ownerUserId)
62
- if (cached) return cached
63
- const handlePromise = resolveOwnerHandle(ctx, ownerUserId)
64
- ownerHandleCache.set(ownerUserId, handlePromise)
65
- return handlePromise
66
- }
67
-
68
- const entries = await Promise.all(
69
- skills.map(async (skill) => {
70
- const [latestVersion, ownerHandle] = await Promise.all([
71
- skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
72
- getOwnerHandle(skill.ownerUserId),
73
- ])
74
- const badges = badgeMapBySkillId.get(skill._id) ?? {}
75
- const publicSkill = toPublicSkill({ ...skill, badges })
76
- if (!publicSkill) return null
77
- return { skill: publicSkill, latestVersion, ownerHandle }
78
- }),
79
- )
80
-
81
- return entries.filter((entry): entry is PublicSkillEntry => entry !== null)
82
- }
83
-
84
- async function buildManagementSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
85
- const ownerCache = new Map<Id<'users'>, Promise<Doc<'users'> | null>>()
86
- const badgeMapBySkillId = await getSkillBadgeMaps(
87
- ctx,
88
- skills.map((skill) => skill._id),
89
- )
90
-
91
- const getOwner = (ownerUserId: Id<'users'>) => {
92
- const cached = ownerCache.get(ownerUserId)
93
- if (cached) return cached
94
- const ownerPromise = ctx.db.get(ownerUserId)
95
- ownerCache.set(ownerUserId, ownerPromise)
96
- return ownerPromise
97
- }
98
-
99
- return Promise.all(
100
- skills.map(async (skill) => {
101
- const [latestVersion, owner] = await Promise.all([
102
- skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
103
- getOwner(skill.ownerUserId),
104
- ])
105
- const badges = badgeMapBySkillId.get(skill._id) ?? {}
106
- return { skill: { ...skill, badges }, latestVersion, owner }
107
- }),
108
- ) satisfies Promise<ManagementSkillEntry[]>
109
- }
110
-
111
- async function attachBadgesToSkills(ctx: QueryCtx, skills: Doc<'skills'>[]) {
112
- const badgeMapBySkillId = await getSkillBadgeMaps(
113
- ctx,
114
- skills.map((skill) => skill._id),
115
- )
116
- return skills.map((skill) => ({
117
- ...skill,
118
- badges: badgeMapBySkillId.get(skill._id) ?? {},
119
- }))
120
- }
121
-
122
- async function loadHighlightedSkills(ctx: QueryCtx, limit: number) {
123
- const entries = await ctx.db
124
- .query('skillBadges')
125
- .withIndex('by_kind_at', (q) => q.eq('kind', 'highlighted'))
126
- .order('desc')
127
- .take(MAX_LIST_TAKE)
128
-
129
- const skills: Doc<'skills'>[] = []
130
- for (const badge of entries) {
131
- const skill = await ctx.db.get(badge.skillId)
132
- if (!skill || skill.softDeletedAt) continue
133
- skills.push(skill)
134
- if (skills.length >= limit) break
135
- }
136
-
137
- return skills
138
- }
139
-
140
- async function upsertSkillBadge(
141
- ctx: MutationCtx,
142
- skillId: Id<'skills'>,
143
- kind: BadgeKind,
144
- userId: Id<'users'>,
145
- at: number,
146
- ) {
147
- const existing = await ctx.db
148
- .query('skillBadges')
149
- .withIndex('by_skill_kind', (q) => q.eq('skillId', skillId).eq('kind', kind))
150
- .unique()
151
- if (existing) {
152
- await ctx.db.patch(existing._id, { byUserId: userId, at })
153
- return existing._id
154
- }
155
- return ctx.db.insert('skillBadges', {
156
- skillId,
157
- kind,
158
- byUserId: userId,
159
- at,
160
- })
161
- }
162
-
163
- async function removeSkillBadge(ctx: MutationCtx, skillId: Id<'skills'>, kind: BadgeKind) {
164
- const existing = await ctx.db
165
- .query('skillBadges')
166
- .withIndex('by_skill_kind', (q) => q.eq('skillId', skillId).eq('kind', kind))
167
- .unique()
168
- if (existing) {
169
- await ctx.db.delete(existing._id)
170
- }
171
- }
172
-
173
- export const getBySlug = query({
174
- args: { slug: v.string() },
175
- handler: async (ctx, args) => {
176
- const skill = await ctx.db
177
- .query('skills')
178
- .withIndex('by_slug', (q) => q.eq('slug', args.slug))
179
- .unique()
180
- if (!skill || skill.softDeletedAt) return null
181
- const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
182
- const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
183
- const badges = await getSkillBadgeMap(ctx, skill._id)
184
-
185
- const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null
186
- const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null
187
-
188
- const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null
189
- const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null
190
-
191
- const publicSkill = toPublicSkill({ ...skill, badges })
192
- if (!publicSkill) return null
193
-
194
- return {
195
- skill: publicSkill,
196
- latestVersion,
197
- owner,
198
- forkOf: forkOfSkill
199
- ? {
200
- kind: skill.forkOf?.kind ?? 'fork',
201
- version: skill.forkOf?.version ?? null,
202
- skill: {
203
- slug: forkOfSkill.slug,
204
- displayName: forkOfSkill.displayName,
205
- },
206
- owner: {
207
- handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
208
- userId: forkOfOwner?._id ?? null,
209
- },
210
- }
211
- : null,
212
- canonical: canonicalSkill
213
- ? {
214
- skill: {
215
- slug: canonicalSkill.slug,
216
- displayName: canonicalSkill.displayName,
217
- },
218
- owner: {
219
- handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
220
- userId: canonicalOwner?._id ?? null,
221
- },
222
- }
223
- : null,
224
- }
225
- },
226
- })
227
-
228
- export const getSkillBySlugInternal = internalQuery({
229
- args: { slug: v.string() },
230
- handler: async (ctx, args) => {
231
- return ctx.db
232
- .query('skills')
233
- .withIndex('by_slug', (q) => q.eq('slug', args.slug))
234
- .unique()
235
- },
236
- })
237
-
238
- export const list = query({
239
- args: {
240
- batch: v.optional(v.string()),
241
- ownerUserId: v.optional(v.id('users')),
242
- limit: v.optional(v.number()),
243
- },
244
- handler: async (ctx, args) => {
245
- const limit = clampInt(args.limit ?? 24, 1, MAX_LIST_BULK_LIMIT)
246
- const takeLimit = Math.min(limit * 5, MAX_LIST_TAKE)
247
- if (args.batch) {
248
- if (args.batch === 'highlighted') {
249
- const skills = await loadHighlightedSkills(ctx, limit)
250
- const withBadges = await attachBadgesToSkills(ctx, skills)
251
- return withBadges
252
- .map((skill) => toPublicSkill(skill))
253
- .filter((skill): skill is NonNullable<typeof skill> => Boolean(skill))
254
- }
255
- const entries = await ctx.db
256
- .query('skills')
257
- .withIndex('by_batch', (q) => q.eq('batch', args.batch))
258
- .order('desc')
259
- .take(takeLimit)
260
- const filtered = entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
261
- const withBadges = await attachBadgesToSkills(ctx, filtered)
262
- return withBadges
263
- .map((skill) => toPublicSkill(skill))
264
- .filter((skill): skill is NonNullable<typeof skill> => Boolean(skill))
265
- }
266
- const ownerUserId = args.ownerUserId
267
- if (ownerUserId) {
268
- const entries = await ctx.db
269
- .query('skills')
270
- .withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
271
- .order('desc')
272
- .take(takeLimit)
273
- const filtered = entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
274
- const withBadges = await attachBadgesToSkills(ctx, filtered)
275
- return withBadges
276
- .map((skill) => toPublicSkill(skill))
277
- .filter((skill): skill is NonNullable<typeof skill> => Boolean(skill))
278
- }
279
- const entries = await ctx.db.query('skills').order('desc').take(takeLimit)
280
- const filtered = entries.filter((skill) => !skill.softDeletedAt).slice(0, limit)
281
- const withBadges = await attachBadgesToSkills(ctx, filtered)
282
- return withBadges
283
- .map((skill) => toPublicSkill(skill))
284
- .filter((skill): skill is NonNullable<typeof skill> => Boolean(skill))
285
- },
286
- })
287
-
288
- export const listWithLatest = query({
289
- args: {
290
- batch: v.optional(v.string()),
291
- ownerUserId: v.optional(v.id('users')),
292
- limit: v.optional(v.number()),
293
- },
294
- handler: async (ctx, args) => {
295
- const limit = clampInt(args.limit ?? 24, 1, MAX_LIST_BULK_LIMIT)
296
- const takeLimit = Math.min(limit * 5, MAX_LIST_TAKE)
297
- let entries: Doc<'skills'>[] = []
298
- if (args.batch) {
299
- if (args.batch === 'highlighted') {
300
- entries = await loadHighlightedSkills(ctx, limit)
301
- } else {
302
- entries = await ctx.db
303
- .query('skills')
304
- .withIndex('by_batch', (q) => q.eq('batch', args.batch))
305
- .order('desc')
306
- .take(takeLimit)
307
- }
308
- } else if (args.ownerUserId) {
309
- const ownerUserId = args.ownerUserId
310
- entries = await ctx.db
311
- .query('skills')
312
- .withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
313
- .order('desc')
314
- .take(takeLimit)
315
- } else {
316
- entries = await ctx.db.query('skills').order('desc').take(takeLimit)
317
- }
318
-
319
- const filtered = entries.filter((skill) => !skill.softDeletedAt)
320
- const withBadges = await attachBadgesToSkills(ctx, filtered)
321
- const ordered =
322
- args.batch === 'highlighted'
323
- ? [...withBadges].sort(
324
- (a, b) => (b.badges?.highlighted?.at ?? 0) - (a.badges?.highlighted?.at ?? 0),
325
- )
326
- : withBadges
327
- const limited = ordered.slice(0, limit)
328
- const items = await Promise.all(
329
- limited.map(async (skill) => ({
330
- skill: toPublicSkill(skill),
331
- latestVersion: skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null,
332
- })),
333
- )
334
- return items.filter(
335
- (
336
- item,
337
- ): item is {
338
- skill: NonNullable<ReturnType<typeof toPublicSkill>>
339
- latestVersion: Doc<'skillVersions'> | null
340
- } => Boolean(item.skill),
341
- )
342
- },
343
- })
344
-
345
- export const listForManagement = query({
346
- args: {
347
- limit: v.optional(v.number()),
348
- includeDeleted: v.optional(v.boolean()),
349
- },
350
- handler: async (ctx, args) => {
351
- const { user } = await requireUser(ctx)
352
- assertModerator(user)
353
- const limit = clampInt(args.limit ?? 50, 1, MAX_LIST_BULK_LIMIT)
354
- const takeLimit = Math.min(limit * 5, MAX_LIST_TAKE)
355
- const entries = await ctx.db.query('skills').order('desc').take(takeLimit)
356
- const filtered = (
357
- args.includeDeleted ? entries : entries.filter((skill) => !skill.softDeletedAt)
358
- ).slice(0, limit)
359
- return buildManagementSkillEntries(ctx, filtered)
360
- },
361
- })
362
-
363
- export const listRecentVersions = query({
364
- args: { limit: v.optional(v.number()) },
365
- handler: async (ctx, args) => {
366
- const { user } = await requireUser(ctx)
367
- assertModerator(user)
368
- const limit = clampInt(args.limit ?? 20, 1, MAX_LIST_BULK_LIMIT)
369
- const versions = await ctx.db
370
- .query('skillVersions')
371
- .order('desc')
372
- .take(limit * 2)
373
- const entries = versions.filter((version) => !version.softDeletedAt).slice(0, limit)
374
-
375
- const results: Array<{
376
- version: Doc<'skillVersions'>
377
- skill: Doc<'skills'> | null
378
- owner: Doc<'users'> | null
379
- }> = []
380
-
381
- for (const version of entries) {
382
- const skill = await ctx.db.get(version.skillId)
383
- if (!skill) {
384
- results.push({ version, skill: null, owner: null })
385
- continue
386
- }
387
- const owner = await ctx.db.get(skill.ownerUserId)
388
- results.push({ version, skill, owner })
389
- }
390
-
391
- return results
392
- },
393
- })
394
-
395
- export const listReportedSkills = query({
396
- args: { limit: v.optional(v.number()) },
397
- handler: async (ctx, args) => {
398
- const { user } = await requireUser(ctx)
399
- assertModerator(user)
400
- const limit = clampInt(args.limit ?? 25, 1, MAX_LIST_BULK_LIMIT)
401
- const takeLimit = Math.min(limit * 5, MAX_LIST_TAKE)
402
- const entries = await ctx.db.query('skills').order('desc').take(takeLimit)
403
- const reported = entries
404
- .filter((skill) => (skill.reportCount ?? 0) > 0)
405
- .sort((a, b) => (b.lastReportedAt ?? 0) - (a.lastReportedAt ?? 0))
406
- .slice(0, limit)
407
- return buildManagementSkillEntries(ctx, reported)
408
- },
409
- })
410
-
411
- export const listDuplicateCandidates = query({
412
- args: { limit: v.optional(v.number()) },
413
- handler: async (ctx, args) => {
414
- const { user } = await requireUser(ctx)
415
- assertModerator(user)
416
- const limit = clampInt(args.limit ?? 20, 1, MAX_LIST_BULK_LIMIT)
417
- const takeLimit = Math.min(limit * 5, MAX_LIST_TAKE)
418
- const skills = await ctx.db.query('skills').order('desc').take(takeLimit)
419
- const entries = skills.filter((skill) => !skill.softDeletedAt).slice(0, limit)
420
-
421
- const results: Array<{
422
- skill: Doc<'skills'>
423
- latestVersion: Doc<'skillVersions'> | null
424
- fingerprint: string | null
425
- matches: Array<{ skill: Doc<'skills'>; owner: Doc<'users'> | null }>
426
- owner: Doc<'users'> | null
427
- }> = []
428
-
429
- for (const skill of entries) {
430
- const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
431
- const fingerprint = latestVersion?.fingerprint ?? null
432
- if (!fingerprint) continue
433
-
434
- const matchedFingerprints = await ctx.db
435
- .query('skillVersionFingerprints')
436
- .withIndex('by_fingerprint', (q) => q.eq('fingerprint', fingerprint))
437
- .take(10)
438
-
439
- const matchEntries: Array<{ skill: Doc<'skills'>; owner: Doc<'users'> | null }> = []
440
- for (const match of matchedFingerprints) {
441
- if (match.skillId === skill._id) continue
442
- const matchSkill = await ctx.db.get(match.skillId)
443
- if (!matchSkill || matchSkill.softDeletedAt) continue
444
- const matchOwner = await ctx.db.get(matchSkill.ownerUserId)
445
- matchEntries.push({ skill: matchSkill, owner: matchOwner })
446
- }
447
-
448
- if (matchEntries.length === 0) continue
449
-
450
- const owner = await ctx.db.get(skill.ownerUserId)
451
- results.push({
452
- skill,
453
- latestVersion,
454
- fingerprint,
455
- matches: matchEntries,
456
- owner,
457
- })
458
- }
459
-
460
- return results
461
- },
462
- })
463
-
464
- export const report = mutation({
465
- args: { skillId: v.id('skills'), reason: v.optional(v.string()) },
466
- handler: async (ctx, args) => {
467
- const { userId } = await requireUser(ctx)
468
- const skill = await ctx.db.get(args.skillId)
469
- if (!skill || skill.softDeletedAt) throw new Error('Skill not found')
470
-
471
- const existing = await ctx.db
472
- .query('skillReports')
473
- .withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', userId))
474
- .unique()
475
- if (existing) return { ok: true as const, reported: false, alreadyReported: true }
476
-
477
- const now = Date.now()
478
- const reason = args.reason?.trim()
479
- await ctx.db.insert('skillReports', {
480
- skillId: args.skillId,
481
- userId,
482
- reason: reason ? reason.slice(0, 500) : undefined,
483
- createdAt: now,
484
- })
485
-
486
- await ctx.db.patch(skill._id, {
487
- reportCount: (skill.reportCount ?? 0) + 1,
488
- lastReportedAt: now,
489
- updatedAt: now,
490
- })
491
-
492
- return { ok: true as const, reported: true, alreadyReported: false }
493
- },
494
- })
495
-
496
- // TODO: Delete listPublicPage once all clients have migrated to listPublicPageV2
497
- export const listPublicPage = query({
498
- args: {
499
- cursor: v.optional(v.string()),
500
- limit: v.optional(v.number()),
501
- sort: v.optional(
502
- v.union(
503
- v.literal('updated'),
504
- v.literal('downloads'),
505
- v.literal('stars'),
506
- v.literal('installsCurrent'),
507
- v.literal('installsAllTime'),
508
- v.literal('trending'),
509
- ),
510
- ),
511
- },
512
- handler: async (ctx, args) => {
513
- const sort = args.sort ?? 'updated'
514
- const limit = clampInt(args.limit ?? 24, 1, MAX_PUBLIC_LIST_LIMIT)
515
-
516
- if (sort === 'updated') {
517
- const { page, isDone, continueCursor } = await ctx.db
518
- .query('skills')
519
- .withIndex('by_updated', (q) => q)
520
- .order('desc')
521
- .paginate({ cursor: args.cursor ?? null, numItems: limit })
522
-
523
- const skills = page.filter((skill) => !skill.softDeletedAt)
524
- const items = await buildPublicSkillEntries(ctx, skills)
525
-
526
- return { items, nextCursor: isDone ? null : continueCursor }
527
- }
528
-
529
- if (sort === 'trending') {
530
- const entries = await getTrendingEntries(ctx, limit)
531
- const skills: Doc<'skills'>[] = []
532
-
533
- for (const entry of entries) {
534
- const skill = await ctx.db.get(entry.skillId)
535
- if (!skill || skill.softDeletedAt) continue
536
- skills.push(skill)
537
- if (skills.length >= limit) break
538
- }
539
-
540
- const items = await buildPublicSkillEntries(ctx, skills)
541
- return { items, nextCursor: null }
542
- }
543
-
544
- const index = sortToIndex(sort)
545
- const page = await ctx.db
546
- .query('skills')
547
- .withIndex(index, (q) => q)
548
- .order('desc')
549
- .take(Math.min(limit * 5, MAX_LIST_TAKE))
550
-
551
- const filtered = page.filter((skill) => !skill.softDeletedAt).slice(0, limit)
552
- const items = await buildPublicSkillEntries(ctx, filtered)
553
- return { items, nextCursor: null }
554
- },
555
- })
556
-
557
- /**
558
- * V2 of listPublicPage using convex-helpers paginator for better cache behavior.
559
- *
560
- * Key differences from V1:
561
- * - Uses `paginator` from convex-helpers (doesn't track end-cursor internally, better caching)
562
- * - Uses `by_active_updated` index to filter soft-deleted skills at query level
563
- * - Returns standard pagination shape compatible with usePaginatedQuery
564
- */
565
- export const listPublicPageV2 = query({
566
- args: {
567
- paginationOpts: paginationOptsValidator,
568
- },
569
- handler: async (ctx, args) => {
570
- // Use the new index to filter out soft-deleted skills at query time.
571
- // softDeletedAt === undefined means active (non-deleted) skills only.
572
- const result = await paginator(ctx.db, schema)
573
- .query('skills')
574
- .withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
575
- .order('desc')
576
- .paginate(args.paginationOpts)
577
-
578
- // Build the public skill entries (fetch latestVersion + ownerHandle)
579
- const items = await buildPublicSkillEntries(ctx, result.page)
580
-
581
- return {
582
- ...result,
583
- page: items,
584
- }
585
- },
586
- })
587
-
588
- function sortToIndex(
589
- sort: 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime',
590
- ):
591
- | 'by_stats_downloads'
592
- | 'by_stats_stars'
593
- | 'by_stats_installs_current'
594
- | 'by_stats_installs_all_time' {
595
- switch (sort) {
596
- case 'downloads':
597
- return 'by_stats_downloads'
598
- case 'stars':
599
- return 'by_stats_stars'
600
- case 'installsCurrent':
601
- return 'by_stats_installs_current'
602
- case 'installsAllTime':
603
- return 'by_stats_installs_all_time'
604
- }
605
- }
606
-
607
- async function getTrendingEntries(ctx: QueryCtx, limit: number) {
608
- // Use the pre-computed leaderboard from the hourly cron job.
609
- // Avoid Date.now() here to keep the query deterministic and cacheable.
610
- const latest = await ctx.db
611
- .query('skillLeaderboards')
612
- .withIndex('by_kind', (q) => q.eq('kind', 'trending'))
613
- .order('desc')
614
- .take(1)
615
-
616
- if (latest[0]) {
617
- return latest[0].items.slice(0, limit)
618
- }
619
-
620
- // No leaderboard exists yet (cold start) - compute on the fly
621
- const fallback = await buildTrendingLeaderboard(ctx, { limit, now: Date.now() })
622
- return fallback.items
623
- }
624
-
625
- export const listVersions = query({
626
- args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
627
- handler: async (ctx, args) => {
628
- const limit = args.limit ?? 20
629
- return ctx.db
630
- .query('skillVersions')
631
- .withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
632
- .order('desc')
633
- .take(limit)
634
- },
635
- })
636
-
637
- export const listVersionsPage = query({
638
- args: {
639
- skillId: v.id('skills'),
640
- cursor: v.optional(v.string()),
641
- limit: v.optional(v.number()),
642
- },
643
- handler: async (ctx, args) => {
644
- const limit = clampInt(args.limit ?? 20, 1, MAX_LIST_LIMIT)
645
- const { page, isDone, continueCursor } = await ctx.db
646
- .query('skillVersions')
647
- .withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
648
- .order('desc')
649
- .paginate({ cursor: args.cursor ?? null, numItems: limit })
650
- const items = page.filter((version) => !version.softDeletedAt)
651
- return { items, nextCursor: isDone ? null : continueCursor }
652
- },
653
- })
654
-
655
- export const getVersionById = query({
656
- args: { versionId: v.id('skillVersions') },
657
- handler: async (ctx, args) => ctx.db.get(args.versionId),
658
- })
659
-
660
- export const getVersionByIdInternal = internalQuery({
661
- args: { versionId: v.id('skillVersions') },
662
- handler: async (ctx, args) => ctx.db.get(args.versionId),
663
- })
664
-
665
- export const getVersionBySkillAndVersion = query({
666
- args: { skillId: v.id('skills'), version: v.string() },
667
- handler: async (ctx, args) => {
668
- return ctx.db
669
- .query('skillVersions')
670
- .withIndex('by_skill_version', (q) =>
671
- q.eq('skillId', args.skillId).eq('version', args.version),
672
- )
673
- .unique()
674
- },
675
- })
676
-
677
- export const publishVersion: ReturnType<typeof action> = action({
678
- args: {
679
- slug: v.string(),
680
- displayName: v.string(),
681
- version: v.string(),
682
- changelog: v.string(),
683
- tags: v.optional(v.array(v.string())),
684
- forkOf: v.optional(
685
- v.object({
686
- slug: v.string(),
687
- version: v.optional(v.string()),
688
- }),
689
- ),
690
- files: v.array(
691
- v.object({
692
- path: v.string(),
693
- size: v.number(),
694
- storageId: v.id('_storage'),
695
- sha256: v.string(),
696
- contentType: v.optional(v.string()),
697
- }),
698
- ),
699
- },
700
- handler: async (ctx, args): Promise<PublishResult> => {
701
- const { userId } = await requireUserFromAction(ctx)
702
- return publishVersionForUser(ctx, userId, args)
703
- },
704
- })
705
-
706
- export const generateChangelogPreview = action({
707
- args: {
708
- slug: v.string(),
709
- version: v.string(),
710
- readmeText: v.string(),
711
- filePaths: v.optional(v.array(v.string())),
712
- },
713
- handler: async (ctx, args) => {
714
- await requireUserFromAction(ctx)
715
- const changelog = await buildChangelogPreview(ctx, {
716
- slug: args.slug.trim().toLowerCase(),
717
- version: args.version.trim(),
718
- readmeText: args.readmeText,
719
- filePaths: args.filePaths?.map((value) => value.trim()).filter(Boolean),
720
- })
721
- return { changelog, source: 'auto' as const }
722
- },
723
- })
724
-
725
- export const getReadme: ReturnType<typeof action> = action({
726
- args: { versionId: v.id('skillVersions') },
727
- handler: async (ctx, args): Promise<ReadmeResult> => {
728
- const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
729
- versionId: args.versionId,
730
- })) as Doc<'skillVersions'> | null
731
- if (!version) throw new ConvexError('Version not found')
732
- const readmeFile = version.files.find(
733
- (file) => file.path.toLowerCase() === 'skill.md' || file.path.toLowerCase() === 'skills.md',
734
- )
735
- if (!readmeFile) throw new ConvexError('SKILL.md not found')
736
- const text = await fetchText(ctx, readmeFile.storageId)
737
- return { path: readmeFile.path, text }
738
- },
739
- })
740
-
741
- export const getFileText: ReturnType<typeof action> = action({
742
- args: { versionId: v.id('skillVersions'), path: v.string() },
743
- handler: async (ctx, args): Promise<FileTextResult> => {
744
- const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
745
- versionId: args.versionId,
746
- })) as Doc<'skillVersions'> | null
747
- if (!version) throw new ConvexError('Version not found')
748
-
749
- const normalizedPath = args.path.trim()
750
- const normalizedLower = normalizedPath.toLowerCase()
751
- const file =
752
- version.files.find((entry) => entry.path === normalizedPath) ??
753
- version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
754
- if (!file) throw new ConvexError('File not found')
755
- if (file.size > MAX_DIFF_FILE_BYTES) {
756
- throw new ConvexError('File exceeds 200KB limit')
757
- }
758
-
759
- const text = await fetchText(ctx, file.storageId)
760
- return { path: file.path, text, size: file.size, sha256: file.sha256 }
761
- },
762
- })
763
-
764
- export const resolveVersionByHash = query({
765
- args: { slug: v.string(), hash: v.string() },
766
- handler: async (ctx, args) => {
767
- const slug = args.slug.trim().toLowerCase()
768
- const hash = args.hash.trim().toLowerCase()
769
- if (!slug || !/^[a-f0-9]{64}$/.test(hash)) return null
770
-
771
- const skill = await ctx.db
772
- .query('skills')
773
- .withIndex('by_slug', (q) => q.eq('slug', slug))
774
- .unique()
775
- if (!skill || skill.softDeletedAt) return null
776
-
777
- const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
778
-
779
- const fingerprintMatches = await ctx.db
780
- .query('skillVersionFingerprints')
781
- .withIndex('by_skill_fingerprint', (q) => q.eq('skillId', skill._id).eq('fingerprint', hash))
782
- .take(25)
783
-
784
- let match: { version: string } | null = null
785
- if (fingerprintMatches.length > 0) {
786
- const newest = fingerprintMatches.reduce(
787
- (best, entry) => (entry.createdAt > best.createdAt ? entry : best),
788
- fingerprintMatches[0] as (typeof fingerprintMatches)[number],
789
- )
790
- const version = await ctx.db.get(newest.versionId)
791
- if (version && !version.softDeletedAt) {
792
- match = { version: version.version }
793
- }
794
- }
795
-
796
- if (!match) {
797
- const versions = await ctx.db
798
- .query('skillVersions')
799
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
800
- .order('desc')
801
- .take(200)
802
-
803
- for (const version of versions) {
804
- if (version.softDeletedAt) continue
805
- if (typeof version.fingerprint === 'string' && version.fingerprint === hash) {
806
- match = { version: version.version }
807
- break
808
- }
809
-
810
- const fingerprint = await hashSkillFiles(
811
- version.files.map((file) => ({ path: file.path, sha256: file.sha256 })),
812
- )
813
- if (fingerprint === hash) {
814
- match = { version: version.version }
815
- break
816
- }
817
- }
818
- }
819
-
820
- return {
821
- match,
822
- latestVersion: latestVersion ? { version: latestVersion.version } : null,
823
- }
824
- },
825
- })
826
-
827
- export const updateTags = mutation({
828
- args: {
829
- skillId: v.id('skills'),
830
- tags: v.array(v.object({ tag: v.string(), versionId: v.id('skillVersions') })),
831
- },
832
- handler: async (ctx, args) => {
833
- const { user } = await requireUser(ctx)
834
- const skill = await ctx.db.get(args.skillId)
835
- if (!skill) throw new Error('Skill not found')
836
- if (skill.ownerUserId !== user._id) {
837
- assertModerator(user)
838
- }
839
-
840
- const nextTags = { ...skill.tags }
841
- for (const entry of args.tags) {
842
- nextTags[entry.tag] = entry.versionId
843
- }
844
-
845
- const latestEntry = args.tags.find((entry) => entry.tag === 'latest')
846
- await ctx.db.patch(skill._id, {
847
- tags: nextTags,
848
- latestVersionId: latestEntry ? latestEntry.versionId : skill.latestVersionId,
849
- updatedAt: Date.now(),
850
- })
851
-
852
- if (latestEntry) {
853
- const embeddings = await ctx.db
854
- .query('skillEmbeddings')
855
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
856
- .collect()
857
- for (const embedding of embeddings) {
858
- const isLatest = embedding.versionId === latestEntry.versionId
859
- await ctx.db.patch(embedding._id, {
860
- isLatest,
861
- visibility: visibilityFor(isLatest, embedding.isApproved),
862
- updatedAt: Date.now(),
863
- })
864
- }
865
- }
866
- },
867
- })
868
-
869
- export const setRedactionApproved = mutation({
870
- args: { skillId: v.id('skills'), approved: v.boolean() },
871
- handler: async (ctx, args) => {
872
- const { user } = await requireUser(ctx)
873
- assertAdmin(user)
874
-
875
- const skill = await ctx.db.get(args.skillId)
876
- if (!skill) throw new Error('Skill not found')
877
-
878
- const now = Date.now()
879
- if (args.approved) {
880
- await upsertSkillBadge(ctx, skill._id, 'redactionApproved', user._id, now)
881
- } else {
882
- await removeSkillBadge(ctx, skill._id, 'redactionApproved')
883
- }
884
-
885
- await ctx.db.patch(skill._id, {
886
- lastReviewedAt: now,
887
- updatedAt: now,
888
- })
889
-
890
- const embeddings = await ctx.db
891
- .query('skillEmbeddings')
892
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
893
- .collect()
894
- for (const embedding of embeddings) {
895
- await ctx.db.patch(embedding._id, {
896
- isApproved: args.approved,
897
- visibility: visibilityFor(embedding.isLatest, args.approved),
898
- updatedAt: now,
899
- })
900
- }
901
-
902
- await ctx.db.insert('auditLogs', {
903
- actorUserId: user._id,
904
- action: args.approved ? 'badge.set' : 'badge.unset',
905
- targetType: 'skill',
906
- targetId: skill._id,
907
- metadata: { badge: 'redactionApproved', approved: args.approved },
908
- createdAt: now,
909
- })
910
- },
911
- })
912
-
913
- export const setBatch = mutation({
914
- args: { skillId: v.id('skills'), batch: v.optional(v.string()) },
915
- handler: async (ctx, args) => {
916
- const { user } = await requireUser(ctx)
917
- assertModerator(user)
918
- const skill = await ctx.db.get(args.skillId)
919
- if (!skill) throw new Error('Skill not found')
920
- const existingBadges = await getSkillBadgeMap(ctx, skill._id)
921
- const previousHighlighted = isSkillHighlighted({ badges: existingBadges })
922
- const nextBatch = args.batch?.trim() || undefined
923
- const nextHighlighted = nextBatch === 'highlighted'
924
- const now = Date.now()
925
-
926
- if (nextHighlighted) {
927
- await upsertSkillBadge(ctx, skill._id, 'highlighted', user._id, now)
928
- } else {
929
- await removeSkillBadge(ctx, skill._id, 'highlighted')
930
- }
931
-
932
- await ctx.db.patch(skill._id, {
933
- batch: nextBatch,
934
- updatedAt: now,
935
- })
936
- await ctx.db.insert('auditLogs', {
937
- actorUserId: user._id,
938
- action: 'badge.highlighted',
939
- targetType: 'skill',
940
- targetId: skill._id,
941
- metadata: { highlighted: nextHighlighted },
942
- createdAt: now,
943
- })
944
-
945
- if (nextHighlighted && !previousHighlighted) {
946
- void queueHighlightedWebhook(ctx, skill._id)
947
- }
948
- },
949
- })
950
-
951
- export const setSoftDeleted = mutation({
952
- args: { skillId: v.id('skills'), deleted: v.boolean() },
953
- handler: async (ctx, args) => {
954
- const { user } = await requireUser(ctx)
955
- assertModerator(user)
956
- const skill = await ctx.db.get(args.skillId)
957
- if (!skill) throw new Error('Skill not found')
958
-
959
- const now = Date.now()
960
- await ctx.db.patch(skill._id, {
961
- softDeletedAt: args.deleted ? now : undefined,
962
- moderationStatus: args.deleted ? 'hidden' : 'active',
963
- hiddenAt: args.deleted ? now : undefined,
964
- hiddenBy: args.deleted ? user._id : undefined,
965
- lastReviewedAt: now,
966
- updatedAt: now,
967
- })
968
-
969
- const embeddings = await ctx.db
970
- .query('skillEmbeddings')
971
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
972
- .collect()
973
- for (const embedding of embeddings) {
974
- await ctx.db.patch(embedding._id, {
975
- visibility: args.deleted
976
- ? 'deleted'
977
- : visibilityFor(embedding.isLatest, embedding.isApproved),
978
- updatedAt: now,
979
- })
980
- }
981
-
982
- await ctx.db.insert('auditLogs', {
983
- actorUserId: user._id,
984
- action: args.deleted ? 'skill.delete' : 'skill.undelete',
985
- targetType: 'skill',
986
- targetId: skill._id,
987
- metadata: { slug: skill.slug, softDeletedAt: args.deleted ? now : null },
988
- createdAt: now,
989
- })
990
- },
991
- })
992
-
993
- export const changeOwner = mutation({
994
- args: { skillId: v.id('skills'), ownerUserId: v.id('users') },
995
- handler: async (ctx, args) => {
996
- const { user } = await requireUser(ctx)
997
- assertAdmin(user)
998
- const skill = await ctx.db.get(args.skillId)
999
- if (!skill) throw new Error('Skill not found')
1000
-
1001
- const nextOwner = await ctx.db.get(args.ownerUserId)
1002
- if (!nextOwner || nextOwner.deletedAt) throw new Error('User not found')
1003
-
1004
- if (skill.ownerUserId === args.ownerUserId) return
1005
-
1006
- const now = Date.now()
1007
- await ctx.db.patch(skill._id, {
1008
- ownerUserId: args.ownerUserId,
1009
- lastReviewedAt: now,
1010
- updatedAt: now,
1011
- })
1012
-
1013
- const embeddings = await ctx.db
1014
- .query('skillEmbeddings')
1015
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1016
- .collect()
1017
- for (const embedding of embeddings) {
1018
- await ctx.db.patch(embedding._id, {
1019
- ownerId: args.ownerUserId,
1020
- updatedAt: now,
1021
- })
1022
- }
1023
-
1024
- await ctx.db.insert('auditLogs', {
1025
- actorUserId: user._id,
1026
- action: 'skill.owner.change',
1027
- targetType: 'skill',
1028
- targetId: skill._id,
1029
- metadata: { from: skill.ownerUserId, to: args.ownerUserId },
1030
- createdAt: now,
1031
- })
1032
- },
1033
- })
1034
-
1035
- export const setDuplicate = mutation({
1036
- args: { skillId: v.id('skills'), canonicalSlug: v.optional(v.string()) },
1037
- handler: async (ctx, args) => {
1038
- const { user } = await requireUser(ctx)
1039
- assertModerator(user)
1040
- const skill = await ctx.db.get(args.skillId)
1041
- if (!skill) throw new Error('Skill not found')
1042
-
1043
- const now = Date.now()
1044
- const canonicalSlug = args.canonicalSlug?.trim().toLowerCase()
1045
-
1046
- if (!canonicalSlug) {
1047
- await ctx.db.patch(skill._id, {
1048
- canonicalSkillId: undefined,
1049
- forkOf: undefined,
1050
- lastReviewedAt: now,
1051
- updatedAt: now,
1052
- })
1053
- await ctx.db.insert('auditLogs', {
1054
- actorUserId: user._id,
1055
- action: 'skill.duplicate.clear',
1056
- targetType: 'skill',
1057
- targetId: skill._id,
1058
- metadata: { canonicalSlug: null },
1059
- createdAt: now,
1060
- })
1061
- return
1062
- }
1063
-
1064
- const canonical = await ctx.db
1065
- .query('skills')
1066
- .withIndex('by_slug', (q) => q.eq('slug', canonicalSlug))
1067
- .unique()
1068
- if (!canonical) throw new Error('Canonical skill not found')
1069
- if (canonical._id === skill._id) throw new Error('Cannot duplicate a skill onto itself')
1070
-
1071
- const canonicalVersion = canonical.latestVersionId
1072
- ? await ctx.db.get(canonical.latestVersionId)
1073
- : null
1074
-
1075
- await ctx.db.patch(skill._id, {
1076
- canonicalSkillId: canonical._id,
1077
- forkOf: {
1078
- skillId: canonical._id,
1079
- kind: 'duplicate',
1080
- version: canonicalVersion?.version,
1081
- at: now,
1082
- },
1083
- lastReviewedAt: now,
1084
- updatedAt: now,
1085
- })
1086
-
1087
- await ctx.db.insert('auditLogs', {
1088
- actorUserId: user._id,
1089
- action: 'skill.duplicate.set',
1090
- targetType: 'skill',
1091
- targetId: skill._id,
1092
- metadata: { canonicalSlug },
1093
- createdAt: now,
1094
- })
1095
- },
1096
- })
1097
-
1098
- export const setOfficialBadge = mutation({
1099
- args: { skillId: v.id('skills'), official: v.boolean() },
1100
- handler: async (ctx, args) => {
1101
- const { user } = await requireUser(ctx)
1102
- assertAdmin(user)
1103
- const skill = await ctx.db.get(args.skillId)
1104
- if (!skill) throw new Error('Skill not found')
1105
-
1106
- const now = Date.now()
1107
- if (args.official) {
1108
- await upsertSkillBadge(ctx, skill._id, 'official', user._id, now)
1109
- } else {
1110
- await removeSkillBadge(ctx, skill._id, 'official')
1111
- }
1112
-
1113
- await ctx.db.patch(skill._id, {
1114
- lastReviewedAt: now,
1115
- updatedAt: now,
1116
- })
1117
-
1118
- await ctx.db.insert('auditLogs', {
1119
- actorUserId: user._id,
1120
- action: args.official ? 'badge.official.set' : 'badge.official.unset',
1121
- targetType: 'skill',
1122
- targetId: skill._id,
1123
- metadata: { official: args.official },
1124
- createdAt: now,
1125
- })
1126
- },
1127
- })
1128
-
1129
- export const setDeprecatedBadge = mutation({
1130
- args: { skillId: v.id('skills'), deprecated: v.boolean() },
1131
- handler: async (ctx, args) => {
1132
- const { user } = await requireUser(ctx)
1133
- assertAdmin(user)
1134
- const skill = await ctx.db.get(args.skillId)
1135
- if (!skill) throw new Error('Skill not found')
1136
-
1137
- const now = Date.now()
1138
- if (args.deprecated) {
1139
- await upsertSkillBadge(ctx, skill._id, 'deprecated', user._id, now)
1140
- } else {
1141
- await removeSkillBadge(ctx, skill._id, 'deprecated')
1142
- }
1143
-
1144
- await ctx.db.patch(skill._id, {
1145
- lastReviewedAt: now,
1146
- updatedAt: now,
1147
- })
1148
-
1149
- await ctx.db.insert('auditLogs', {
1150
- actorUserId: user._id,
1151
- action: args.deprecated ? 'badge.deprecated.set' : 'badge.deprecated.unset',
1152
- targetType: 'skill',
1153
- targetId: skill._id,
1154
- metadata: { deprecated: args.deprecated },
1155
- createdAt: now,
1156
- })
1157
- },
1158
- })
1159
-
1160
- export const hardDelete = mutation({
1161
- args: { skillId: v.id('skills') },
1162
- handler: async (ctx, args) => {
1163
- const { user } = await requireUser(ctx)
1164
- assertAdmin(user)
1165
- const skill = await ctx.db.get(args.skillId)
1166
- if (!skill) throw new Error('Skill not found')
1167
-
1168
- const versions = await ctx.db
1169
- .query('skillVersions')
1170
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1171
- .collect()
1172
-
1173
- for (const version of versions) {
1174
- const versionFingerprints = await ctx.db
1175
- .query('skillVersionFingerprints')
1176
- .withIndex('by_version', (q) => q.eq('versionId', version._id))
1177
- .collect()
1178
- for (const fingerprint of versionFingerprints) {
1179
- await ctx.db.delete(fingerprint._id)
1180
- }
1181
-
1182
- const embeddings = await ctx.db
1183
- .query('skillEmbeddings')
1184
- .withIndex('by_version', (q) => q.eq('versionId', version._id))
1185
- .collect()
1186
- for (const embedding of embeddings) {
1187
- await ctx.db.delete(embedding._id)
1188
- }
1189
-
1190
- await ctx.db.delete(version._id)
1191
- }
1192
-
1193
- const remainingFingerprints = await ctx.db
1194
- .query('skillVersionFingerprints')
1195
- .withIndex('by_skill_fingerprint', (q) => q.eq('skillId', skill._id))
1196
- .collect()
1197
- for (const fingerprint of remainingFingerprints) {
1198
- await ctx.db.delete(fingerprint._id)
1199
- }
1200
-
1201
- const remainingEmbeddings = await ctx.db
1202
- .query('skillEmbeddings')
1203
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1204
- .collect()
1205
- for (const embedding of remainingEmbeddings) {
1206
- await ctx.db.delete(embedding._id)
1207
- }
1208
-
1209
- const comments = await ctx.db
1210
- .query('comments')
1211
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1212
- .collect()
1213
- for (const comment of comments) {
1214
- await ctx.db.delete(comment._id)
1215
- }
1216
-
1217
- const stars = await ctx.db
1218
- .query('stars')
1219
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1220
- .collect()
1221
- for (const star of stars) {
1222
- await ctx.db.delete(star._id)
1223
- }
1224
-
1225
- const badges = await ctx.db
1226
- .query('skillBadges')
1227
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1228
- .collect()
1229
- for (const badge of badges) {
1230
- await ctx.db.delete(badge._id)
1231
- }
1232
-
1233
- const dailyStats = await ctx.db
1234
- .query('skillDailyStats')
1235
- .withIndex('by_skill_day', (q) => q.eq('skillId', skill._id))
1236
- .collect()
1237
- for (const stat of dailyStats) {
1238
- await ctx.db.delete(stat._id)
1239
- }
1240
-
1241
- const statEvents = await ctx.db
1242
- .query('skillStatEvents')
1243
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1244
- .collect()
1245
- for (const statEvent of statEvents) {
1246
- await ctx.db.delete(statEvent._id)
1247
- }
1248
-
1249
- const installs = await ctx.db
1250
- .query('userSkillInstalls')
1251
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1252
- .collect()
1253
- for (const install of installs) {
1254
- await ctx.db.delete(install._id)
1255
- }
1256
-
1257
- const rootInstalls = await ctx.db
1258
- .query('userSkillRootInstalls')
1259
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1260
- .collect()
1261
- for (const rootInstall of rootInstalls) {
1262
- await ctx.db.delete(rootInstall._id)
1263
- }
1264
-
1265
- const leaderboards = await ctx.db.query('skillLeaderboards').collect()
1266
- for (const leaderboard of leaderboards) {
1267
- const items = leaderboard.items.filter((item) => item.skillId !== skill._id)
1268
- if (items.length !== leaderboard.items.length) {
1269
- await ctx.db.patch(leaderboard._id, { items })
1270
- }
1271
- }
1272
-
1273
- const relatedSkills = await ctx.db.query('skills').collect()
1274
- for (const related of relatedSkills) {
1275
- if (related._id === skill._id) continue
1276
- if (related.canonicalSkillId === skill._id || related.forkOf?.skillId === skill._id) {
1277
- await ctx.db.patch(related._id, {
1278
- canonicalSkillId:
1279
- related.canonicalSkillId === skill._id ? undefined : related.canonicalSkillId,
1280
- forkOf: related.forkOf?.skillId === skill._id ? undefined : related.forkOf,
1281
- updatedAt: Date.now(),
1282
- })
1283
- }
1284
- }
1285
-
1286
- await ctx.db.delete(skill._id)
1287
-
1288
- await ctx.db.insert('auditLogs', {
1289
- actorUserId: user._id,
1290
- action: 'skill.hard_delete',
1291
- targetType: 'skill',
1292
- targetId: skill._id,
1293
- metadata: { slug: skill.slug },
1294
- createdAt: Date.now(),
1295
- })
1296
- },
1297
- })
1298
-
1299
- export const insertVersion = internalMutation({
1300
- args: {
1301
- userId: v.id('users'),
1302
- slug: v.string(),
1303
- displayName: v.string(),
1304
- version: v.string(),
1305
- changelog: v.string(),
1306
- changelogSource: v.optional(v.union(v.literal('auto'), v.literal('user'))),
1307
- tags: v.optional(v.array(v.string())),
1308
- fingerprint: v.string(),
1309
- forkOf: v.optional(
1310
- v.object({
1311
- slug: v.string(),
1312
- version: v.optional(v.string()),
1313
- }),
1314
- ),
1315
- files: v.array(
1316
- v.object({
1317
- path: v.string(),
1318
- size: v.number(),
1319
- storageId: v.id('_storage'),
1320
- sha256: v.string(),
1321
- contentType: v.optional(v.string()),
1322
- }),
1323
- ),
1324
- parsed: v.object({
1325
- frontmatter: v.record(v.string(), v.any()),
1326
- metadata: v.optional(v.any()),
1327
- pilotbot: v.optional(v.any()),
1328
- }),
1329
- embedding: v.array(v.number()),
1330
- },
1331
- handler: async (ctx, args) => {
1332
- const userId = args.userId
1333
- const user = await ctx.db.get(userId)
1334
- if (!user || user.deletedAt) throw new Error('User not found')
1335
-
1336
- let skill = await ctx.db
1337
- .query('skills')
1338
- .withIndex('by_slug', (q) => q.eq('slug', args.slug))
1339
- .unique()
1340
-
1341
- if (skill && skill.ownerUserId !== userId) {
1342
- throw new Error('Only the owner can publish updates')
1343
- }
1344
-
1345
- const now = Date.now()
1346
- if (!skill) {
1347
- const forkOfSlug = args.forkOf?.slug.trim().toLowerCase() || ''
1348
- const forkOfVersion = args.forkOf?.version?.trim() || undefined
1349
-
1350
- let canonicalSkillId: Id<'skills'> | undefined
1351
- let forkOf:
1352
- | {
1353
- skillId: Id<'skills'>
1354
- kind: 'fork' | 'duplicate'
1355
- version?: string
1356
- at: number
1357
- }
1358
- | undefined
1359
-
1360
- if (forkOfSlug) {
1361
- const upstream = await ctx.db
1362
- .query('skills')
1363
- .withIndex('by_slug', (q) => q.eq('slug', forkOfSlug))
1364
- .unique()
1365
- if (!upstream || upstream.softDeletedAt) throw new Error('Upstream skill not found')
1366
- canonicalSkillId = upstream.canonicalSkillId ?? upstream._id
1367
- forkOf = {
1368
- skillId: upstream._id,
1369
- kind: 'fork',
1370
- version: forkOfVersion,
1371
- at: now,
1372
- }
1373
- } else {
1374
- const match = await findCanonicalSkillForFingerprint(ctx, args.fingerprint)
1375
- if (match) {
1376
- canonicalSkillId = match.canonicalSkillId ?? match._id
1377
- forkOf = {
1378
- skillId: match._id,
1379
- kind: 'duplicate',
1380
- at: now,
1381
- }
1382
- }
1383
- }
1384
-
1385
- const summary = getFrontmatterValue(args.parsed.frontmatter, 'description')
1386
- const summaryValue = summary ?? undefined
1387
- const moderationFlags = deriveModerationFlags({
1388
- skill: { slug: args.slug, displayName: args.displayName, summary: summaryValue },
1389
- parsed: args.parsed,
1390
- files: args.files,
1391
- })
1392
- const skillId = await ctx.db.insert('skills', {
1393
- slug: args.slug,
1394
- displayName: args.displayName,
1395
- summary: summaryValue,
1396
- ownerUserId: userId,
1397
- canonicalSkillId,
1398
- forkOf,
1399
- latestVersionId: undefined,
1400
- tags: {},
1401
- softDeletedAt: undefined,
1402
- badges: {
1403
- redactionApproved: undefined,
1404
- highlighted: undefined,
1405
- official: undefined,
1406
- deprecated: undefined,
1407
- },
1408
- moderationStatus: 'active',
1409
- moderationFlags: moderationFlags.length ? moderationFlags : undefined,
1410
- reportCount: 0,
1411
- lastReportedAt: undefined,
1412
- statsDownloads: 0,
1413
- statsStars: 0,
1414
- statsInstallsCurrent: 0,
1415
- statsInstallsAllTime: 0,
1416
- stats: {
1417
- downloads: 0,
1418
- installsCurrent: 0,
1419
- installsAllTime: 0,
1420
- stars: 0,
1421
- versions: 0,
1422
- comments: 0,
1423
- },
1424
- createdAt: now,
1425
- updatedAt: now,
1426
- })
1427
- skill = await ctx.db.get(skillId)
1428
- }
1429
-
1430
- if (!skill) throw new Error('Skill creation failed')
1431
-
1432
- const existingVersion = await ctx.db
1433
- .query('skillVersions')
1434
- .withIndex('by_skill_version', (q) => q.eq('skillId', skill._id).eq('version', args.version))
1435
- .unique()
1436
- if (existingVersion) {
1437
- throw new Error('Version already exists')
1438
- }
1439
-
1440
- const versionId = await ctx.db.insert('skillVersions', {
1441
- skillId: skill._id,
1442
- version: args.version,
1443
- fingerprint: args.fingerprint,
1444
- changelog: args.changelog,
1445
- changelogSource: args.changelogSource,
1446
- files: args.files,
1447
- parsed: args.parsed,
1448
- createdBy: userId,
1449
- createdAt: now,
1450
- softDeletedAt: undefined,
1451
- })
1452
-
1453
- const nextTags: Record<string, Id<'skillVersions'>> = { ...skill.tags }
1454
- nextTags.latest = versionId
1455
- for (const tag of args.tags ?? []) {
1456
- nextTags[tag] = versionId
1457
- }
1458
-
1459
- const latestBefore = skill.latestVersionId
1460
-
1461
- const nextSummary = getFrontmatterValue(args.parsed.frontmatter, 'description') ?? skill.summary
1462
- const moderationFlags = deriveModerationFlags({
1463
- skill: { slug: skill.slug, displayName: args.displayName, summary: nextSummary ?? undefined },
1464
- parsed: args.parsed,
1465
- files: args.files,
1466
- })
1467
-
1468
- await ctx.db.patch(skill._id, {
1469
- displayName: args.displayName,
1470
- summary: nextSummary ?? undefined,
1471
- latestVersionId: versionId,
1472
- tags: nextTags,
1473
- stats: { ...skill.stats, versions: skill.stats.versions + 1 },
1474
- softDeletedAt: undefined,
1475
- moderationStatus: skill.moderationStatus ?? 'active',
1476
- moderationFlags: moderationFlags.length ? moderationFlags : undefined,
1477
- updatedAt: now,
1478
- })
1479
-
1480
- const badgeMap = await getSkillBadgeMap(ctx, skill._id)
1481
- const isApproved = Boolean(badgeMap.redactionApproved)
1482
-
1483
- const embeddingId = await ctx.db.insert('skillEmbeddings', {
1484
- skillId: skill._id,
1485
- versionId,
1486
- ownerId: userId,
1487
- embedding: args.embedding,
1488
- isLatest: true,
1489
- isApproved,
1490
- visibility: visibilityFor(true, isApproved),
1491
- updatedAt: now,
1492
- })
1493
-
1494
- if (latestBefore) {
1495
- const previousEmbedding = await ctx.db
1496
- .query('skillEmbeddings')
1497
- .withIndex('by_version', (q) => q.eq('versionId', latestBefore))
1498
- .unique()
1499
- if (previousEmbedding) {
1500
- await ctx.db.patch(previousEmbedding._id, {
1501
- isLatest: false,
1502
- visibility: visibilityFor(false, previousEmbedding.isApproved),
1503
- updatedAt: now,
1504
- })
1505
- }
1506
- }
1507
-
1508
- await ctx.db.insert('skillVersionFingerprints', {
1509
- skillId: skill._id,
1510
- versionId,
1511
- fingerprint: args.fingerprint,
1512
- createdAt: now,
1513
- })
1514
-
1515
- return { skillId: skill._id, versionId, embeddingId }
1516
- },
1517
- })
1518
-
1519
- export const setSkillSoftDeletedInternal = internalMutation({
1520
- args: {
1521
- userId: v.id('users'),
1522
- slug: v.string(),
1523
- deleted: v.boolean(),
1524
- },
1525
- handler: async (ctx, args) => {
1526
- const user = await ctx.db.get(args.userId)
1527
- if (!user || user.deletedAt) throw new Error('User not found')
1528
-
1529
- const slug = args.slug.trim().toLowerCase()
1530
- if (!slug) throw new Error('Slug required')
1531
-
1532
- const skill = await ctx.db
1533
- .query('skills')
1534
- .withIndex('by_slug', (q) => q.eq('slug', slug))
1535
- .unique()
1536
- if (!skill) throw new Error('Skill not found')
1537
-
1538
- if (skill.ownerUserId !== args.userId) {
1539
- assertModerator(user)
1540
- }
1541
-
1542
- const now = Date.now()
1543
- await ctx.db.patch(skill._id, {
1544
- softDeletedAt: args.deleted ? now : undefined,
1545
- moderationStatus: args.deleted ? 'hidden' : 'active',
1546
- hiddenAt: args.deleted ? now : undefined,
1547
- hiddenBy: args.deleted ? args.userId : undefined,
1548
- lastReviewedAt: now,
1549
- updatedAt: now,
1550
- })
1551
-
1552
- const embeddings = await ctx.db
1553
- .query('skillEmbeddings')
1554
- .withIndex('by_skill', (q) => q.eq('skillId', skill._id))
1555
- .collect()
1556
- for (const embedding of embeddings) {
1557
- await ctx.db.patch(embedding._id, {
1558
- visibility: args.deleted
1559
- ? 'deleted'
1560
- : visibilityFor(embedding.isLatest, embedding.isApproved),
1561
- updatedAt: now,
1562
- })
1563
- }
1564
-
1565
- await ctx.db.insert('auditLogs', {
1566
- actorUserId: args.userId,
1567
- action: args.deleted ? 'skill.delete' : 'skill.undelete',
1568
- targetType: 'skill',
1569
- targetId: skill._id,
1570
- metadata: { slug, softDeletedAt: args.deleted ? now : null },
1571
- createdAt: now,
1572
- })
1573
-
1574
- return { ok: true as const }
1575
- },
1576
- })
1577
-
1578
- function visibilityFor(isLatest: boolean, isApproved: boolean) {
1579
- if (isLatest && isApproved) return 'latest-approved'
1580
- if (isLatest) return 'latest'
1581
- if (isApproved) return 'archived-approved'
1582
- return 'archived'
1583
- }
1584
-
1585
- function clampInt(value: number, min: number, max: number) {
1586
- const rounded = Number.isFinite(value) ? Math.round(value) : min
1587
- return Math.min(max, Math.max(min, rounded))
1588
- }
1589
-
1590
- async function findCanonicalSkillForFingerprint(
1591
- ctx: { db: MutationCtx['db'] },
1592
- fingerprint: string,
1593
- ) {
1594
- const matches = await ctx.db
1595
- .query('skillVersionFingerprints')
1596
- .withIndex('by_fingerprint', (q) => q.eq('fingerprint', fingerprint))
1597
- .take(25)
1598
-
1599
- for (const entry of matches) {
1600
- const skill = await ctx.db.get(entry.skillId)
1601
- if (!skill || skill.softDeletedAt) continue
1602
- return skill
1603
- }
1604
-
1605
- return null
1606
- }