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,1606 @@
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
+ }