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.
- package/LICENSE +1 -0
- package/README.md +36 -129
- package/dist/browserAuth.d.ts +20 -0
- package/dist/browserAuth.js +156 -0
- package/dist/browserAuth.js.map +1 -0
- package/dist/browserAuth.test.d.ts +1 -0
- package/dist/browserAuth.test.js +83 -0
- package/dist/browserAuth.test.js.map +1 -0
- package/dist/cli/buildInfo.d.ts +3 -0
- package/dist/cli/buildInfo.js +103 -0
- package/dist/cli/buildInfo.js.map +1 -0
- package/dist/cli/commands/auth.d.ts +9 -0
- package/dist/cli/commands/auth.js +75 -0
- package/dist/cli/commands/auth.js.map +1 -0
- package/dist/cli/commands/delete.d.ts +11 -0
- package/dist/cli/commands/delete.js +67 -0
- package/dist/cli/commands/delete.js.map +1 -0
- package/dist/cli/commands/delete.test.d.ts +1 -0
- package/dist/cli/commands/delete.test.js +52 -0
- package/dist/cli/commands/delete.test.js.map +1 -0
- package/dist/cli/commands/publish.d.ts +9 -0
- package/dist/cli/commands/publish.js +87 -0
- package/dist/cli/commands/publish.js.map +1 -0
- package/dist/cli/commands/publish.test.d.ts +1 -0
- package/dist/cli/commands/publish.test.js +104 -0
- package/dist/cli/commands/publish.test.js.map +1 -0
- package/dist/cli/commands/skills.d.ts +23 -0
- package/dist/cli/commands/skills.js +298 -0
- package/dist/cli/commands/skills.js.map +1 -0
- package/dist/cli/commands/skills.test.d.ts +1 -0
- package/dist/cli/commands/skills.test.js +156 -0
- package/dist/cli/commands/skills.test.js.map +1 -0
- package/dist/cli/commands/star.d.ts +8 -0
- package/dist/cli/commands/star.js +38 -0
- package/dist/cli/commands/star.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +3 -0
- package/dist/cli/commands/sync.js +160 -0
- package/dist/cli/commands/sync.js.map +1 -0
- package/dist/cli/commands/sync.test.d.ts +1 -0
- package/dist/cli/commands/sync.test.js +277 -0
- package/dist/cli/commands/sync.test.js.map +1 -0
- package/dist/cli/commands/syncHelpers.d.ts +76 -0
- package/dist/cli/commands/syncHelpers.js +349 -0
- package/dist/cli/commands/syncHelpers.js.map +1 -0
- package/dist/cli/commands/syncHelpers.test.d.ts +1 -0
- package/dist/cli/commands/syncHelpers.test.js +22 -0
- package/dist/cli/commands/syncHelpers.test.js.map +1 -0
- package/dist/cli/commands/syncTypes.d.ts +24 -0
- package/dist/cli/commands/syncTypes.js +2 -0
- package/dist/cli/commands/syncTypes.js.map +1 -0
- package/dist/cli/commands/unstar.d.ts +8 -0
- package/dist/cli/commands/unstar.js +38 -0
- package/dist/cli/commands/unstar.js.map +1 -0
- package/dist/cli/helpStyle.d.ts +13 -0
- package/dist/cli/helpStyle.js +38 -0
- package/dist/cli/helpStyle.js.map +1 -0
- package/dist/cli/pilotbotConfig.d.ts +6 -0
- package/dist/cli/pilotbotConfig.js +110 -0
- package/dist/cli/pilotbotConfig.js.map +1 -0
- package/dist/cli/pilotbotConfig.test.d.ts +1 -0
- package/dist/cli/pilotbotConfig.test.js +133 -0
- package/dist/cli/pilotbotConfig.test.js.map +1 -0
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.js +42 -0
- package/dist/cli/registry.js.map +1 -0
- package/dist/cli/registry.test.d.ts +1 -0
- package/dist/cli/registry.test.js +48 -0
- package/dist/cli/registry.test.js.map +1 -0
- package/dist/cli/scanSkills.d.ts +7 -0
- package/dist/cli/scanSkills.js +75 -0
- package/dist/cli/scanSkills.js.map +1 -0
- package/dist/cli/scanSkills.test.d.ts +1 -0
- package/dist/cli/scanSkills.test.js +60 -0
- package/dist/cli/scanSkills.test.js.map +1 -0
- package/dist/cli/slug.d.ts +2 -0
- package/dist/cli/slug.js +16 -0
- package/dist/cli/slug.js.map +1 -0
- package/dist/cli/types.d.ts +15 -0
- package/dist/cli/types.js +2 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/cli/ui.d.ts +7 -0
- package/dist/cli/ui.js +72 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +268 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/discovery.d.ts +5 -0
- package/dist/discovery.js +21 -0
- package/dist/discovery.js.map +1 -0
- package/dist/discovery.test.d.ts +1 -0
- package/dist/discovery.test.js +46 -0
- package/dist/discovery.test.js.map +1 -0
- package/dist/http.d.ts +32 -0
- package/dist/http.js +261 -0
- package/dist/http.js.map +1 -0
- package/dist/http.test.d.ts +1 -0
- package/dist/http.test.js +135 -0
- package/dist/http.test.js.map +1 -0
- package/dist/schema/ark.js.map +1 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/routes.js.map +1 -0
- package/{packages/schema/dist → dist/schema}/schemas.d.ts +0 -39
- package/{packages/schema/dist → dist/schema}/schemas.js +0 -22
- package/dist/schema/schemas.js.map +1 -0
- package/dist/schema/textFiles.js.map +1 -0
- package/dist/schema/textFiles.test.d.ts +1 -0
- package/dist/schema/textFiles.test.js +20 -0
- package/dist/schema/textFiles.test.js.map +1 -0
- package/dist/skills.d.ts +43 -0
- package/dist/skills.js +163 -0
- package/dist/skills.js.map +1 -0
- package/dist/skills.test.d.ts +1 -0
- package/dist/skills.test.js +144 -0
- package/dist/skills.test.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +27 -70
- package/.env.local.example +0 -19
- package/.github/workflows/ci.yml +0 -40
- package/.oxlintrc.json +0 -3
- package/AGENTS.md +0 -45
- package/CHANGELOG.md +0 -138
- package/DEPRECATIONS.md +0 -7
- package/biome.json +0 -41
- package/convex/_generated/api.d.ts +0 -153
- package/convex/_generated/api.js +0 -23
- package/convex/_generated/dataModel.d.ts +0 -60
- package/convex/_generated/server.d.ts +0 -143
- package/convex/_generated/server.js +0 -93
- package/convex/auth.config.ts +0 -8
- package/convex/auth.ts +0 -19
- package/convex/comments.ts +0 -88
- package/convex/crons.ts +0 -34
- package/convex/devSeed.ts +0 -459
- package/convex/devSeedExtra.ts +0 -541
- package/convex/downloads.ts +0 -78
- package/convex/githubBackups.ts +0 -170
- package/convex/githubBackupsNode.ts +0 -183
- package/convex/githubImport.ts +0 -317
- package/convex/githubSoulBackups.ts +0 -170
- package/convex/githubSoulBackupsNode.ts +0 -186
- package/convex/http.ts +0 -194
- package/convex/httpApi.handlers.test.ts +0 -488
- package/convex/httpApi.test.ts +0 -70
- package/convex/httpApi.ts +0 -305
- package/convex/httpApiV1.handlers.test.ts +0 -584
- package/convex/httpApiV1.ts +0 -1172
- package/convex/leaderboards.ts +0 -39
- package/convex/lib/access.ts +0 -36
- package/convex/lib/apiTokenAuth.ts +0 -36
- package/convex/lib/badges.ts +0 -50
- package/convex/lib/changelog.test.ts +0 -34
- package/convex/lib/changelog.ts +0 -278
- package/convex/lib/embeddings.ts +0 -38
- package/convex/lib/githubBackup.ts +0 -443
- package/convex/lib/githubImport.test.ts +0 -247
- package/convex/lib/githubImport.ts +0 -425
- package/convex/lib/githubSoulBackup.ts +0 -443
- package/convex/lib/leaderboards.ts +0 -103
- package/convex/lib/moderation.ts +0 -42
- package/convex/lib/public.ts +0 -89
- package/convex/lib/searchText.test.ts +0 -46
- package/convex/lib/searchText.ts +0 -27
- package/convex/lib/skillBackfill.test.ts +0 -34
- package/convex/lib/skillBackfill.ts +0 -67
- package/convex/lib/skillPublish.test.ts +0 -28
- package/convex/lib/skillPublish.ts +0 -284
- package/convex/lib/skillStats.ts +0 -80
- package/convex/lib/skills.test.ts +0 -197
- package/convex/lib/skills.ts +0 -273
- package/convex/lib/soulChangelog.ts +0 -273
- package/convex/lib/soulPublish.ts +0 -236
- package/convex/lib/tokens.test.ts +0 -33
- package/convex/lib/tokens.ts +0 -51
- package/convex/lib/webhooks.test.ts +0 -91
- package/convex/lib/webhooks.ts +0 -112
- package/convex/maintenance.test.ts +0 -270
- package/convex/maintenance.ts +0 -840
- package/convex/rateLimits.ts +0 -50
- package/convex/schema.ts +0 -472
- package/convex/search.test.ts +0 -12
- package/convex/search.ts +0 -254
- package/convex/seed.test.ts +0 -37
- package/convex/seed.ts +0 -254
- package/convex/seedSouls.ts +0 -111
- package/convex/skillStatEvents.ts +0 -568
- package/convex/skills.ts +0 -1606
- package/convex/soulComments.ts +0 -88
- package/convex/soulDownloads.ts +0 -14
- package/convex/soulStars.ts +0 -71
- package/convex/souls.ts +0 -570
- package/convex/stars.ts +0 -108
- package/convex/statsMaintenance.ts +0 -205
- package/convex/telemetry.ts +0 -434
- package/convex/tokens.ts +0 -88
- package/convex/tsconfig.json +0 -7
- package/convex/uploads.ts +0 -20
- package/convex/users.ts +0 -122
- package/convex/webhooks.ts +0 -50
- package/convex.json +0 -3
- package/docs/README.md +0 -32
- package/docs/api.md +0 -51
- package/docs/architecture.md +0 -61
- package/docs/auth.md +0 -54
- package/docs/cli.md +0 -117
- package/docs/deploy.md +0 -78
- package/docs/diffing.md +0 -84
- package/docs/github-import.md +0 -171
- package/docs/http-api.md +0 -187
- package/docs/manual-testing.md +0 -64
- package/docs/mintlify.md +0 -43
- package/docs/quickstart.md +0 -120
- package/docs/skill-format.md +0 -58
- package/docs/soul-format.md +0 -37
- package/docs/spec.md +0 -177
- package/docs/telemetry.md +0 -91
- package/docs/troubleshooting.md +0 -49
- package/docs/webhook.md +0 -51
- package/e2e/menu-smoke.pw.test.ts +0 -49
- package/e2e/pilothub.e2e.test.ts +0 -494
- package/e2e/search-exact.pw.test.ts +0 -97
- package/packages/pilothub/LICENSE +0 -22
- package/packages/pilothub/README.md +0 -57
- package/packages/pilothub/package.json +0 -41
- package/packages/pilothub/src/browserAuth.test.ts +0 -96
- package/packages/pilothub/src/browserAuth.ts +0 -174
- package/packages/pilothub/src/cli/buildInfo.ts +0 -94
- package/packages/pilothub/src/cli/commands/auth.ts +0 -97
- package/packages/pilothub/src/cli/commands/delete.test.ts +0 -73
- package/packages/pilothub/src/cli/commands/delete.ts +0 -83
- package/packages/pilothub/src/cli/commands/publish.test.ts +0 -122
- package/packages/pilothub/src/cli/commands/publish.ts +0 -108
- package/packages/pilothub/src/cli/commands/skills.test.ts +0 -191
- package/packages/pilothub/src/cli/commands/skills.ts +0 -380
- package/packages/pilothub/src/cli/commands/star.ts +0 -46
- package/packages/pilothub/src/cli/commands/sync.test.ts +0 -310
- package/packages/pilothub/src/cli/commands/sync.ts +0 -200
- package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +0 -26
- package/packages/pilothub/src/cli/commands/syncHelpers.ts +0 -427
- package/packages/pilothub/src/cli/commands/syncTypes.ts +0 -27
- package/packages/pilothub/src/cli/commands/unstar.ts +0 -48
- package/packages/pilothub/src/cli/helpStyle.ts +0 -45
- package/packages/pilothub/src/cli/pilotbotConfig.test.ts +0 -159
- package/packages/pilothub/src/cli/pilotbotConfig.ts +0 -147
- package/packages/pilothub/src/cli/registry.test.ts +0 -63
- package/packages/pilothub/src/cli/registry.ts +0 -43
- package/packages/pilothub/src/cli/scanSkills.test.ts +0 -64
- package/packages/pilothub/src/cli/scanSkills.ts +0 -84
- package/packages/pilothub/src/cli/slug.ts +0 -16
- package/packages/pilothub/src/cli/types.ts +0 -12
- package/packages/pilothub/src/cli/ui.ts +0 -75
- package/packages/pilothub/src/cli.ts +0 -311
- package/packages/pilothub/src/config.ts +0 -36
- package/packages/pilothub/src/discovery.test.ts +0 -75
- package/packages/pilothub/src/discovery.ts +0 -19
- package/packages/pilothub/src/http.test.ts +0 -156
- package/packages/pilothub/src/http.ts +0 -301
- package/packages/pilothub/src/schema/ark.ts +0 -29
- package/packages/pilothub/src/schema/index.ts +0 -5
- package/packages/pilothub/src/schema/routes.ts +0 -22
- package/packages/pilothub/src/schema/schemas.ts +0 -260
- package/packages/pilothub/src/schema/textFiles.test.ts +0 -23
- package/packages/pilothub/src/schema/textFiles.ts +0 -66
- package/packages/pilothub/src/skills.test.ts +0 -191
- package/packages/pilothub/src/skills.ts +0 -172
- package/packages/pilothub/src/types.ts +0 -10
- package/packages/pilothub/tsconfig.json +0 -14
- package/packages/schema/README.md +0 -3
- package/packages/schema/dist/ark.js.map +0 -1
- package/packages/schema/dist/index.js.map +0 -1
- package/packages/schema/dist/routes.js.map +0 -1
- package/packages/schema/dist/schemas.js.map +0 -1
- package/packages/schema/dist/textFiles.js.map +0 -1
- package/packages/schema/package.json +0 -26
- package/packages/schema/src/ark.ts +0 -29
- package/packages/schema/src/index.ts +0 -5
- package/packages/schema/src/routes.ts +0 -22
- package/packages/schema/src/schemas.test.ts +0 -123
- package/packages/schema/src/schemas.ts +0 -287
- package/packages/schema/src/textFiles.test.ts +0 -23
- package/packages/schema/src/textFiles.ts +0 -66
- package/packages/schema/tsconfig.json +0 -15
- package/pilothub +0 -46
- package/playwright.config.ts +0 -33
- package/public/.well-known/pilothub.json +0 -6
- package/public/api/v1/openapi.json +0 -379
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +0 -25
- package/public/og.png +0 -0
- package/public/og.svg +0 -98
- package/public/pilot-logo.png +0 -0
- package/public/pilot-mark.png +0 -0
- package/public/robots.txt +0 -3
- package/public/tanstack-circle-logo.png +0 -0
- package/public/tanstack-word-logo-white.svg +0 -1
- package/scripts/check-peer-deps.ts +0 -56
- package/scripts/docs-list.ts +0 -148
- package/scripts/run-playwright-local.sh +0 -14
- package/server/og/fetchSkillOgMeta.ts +0 -27
- package/server/og/fetchSoulOgMeta.ts +0 -27
- package/server/og/ogAssets.ts +0 -80
- package/server/og/skillOgSvg.test.ts +0 -59
- package/server/og/skillOgSvg.ts +0 -258
- package/server/og/soulOgSvg.ts +0 -209
- package/server/routes/og/skill.png.ts +0 -103
- package/server/routes/og/soul.png.ts +0 -111
- package/src/__tests__/skill-detail-page.test.tsx +0 -86
- package/src/__tests__/skills-index.test.tsx +0 -145
- package/src/__tests__/upload.route.test.tsx +0 -228
- package/src/components/AppProviders.tsx +0 -19
- package/src/components/ClientOnly.tsx +0 -18
- package/src/components/Footer.tsx +0 -29
- package/src/components/Header.tsx +0 -295
- package/src/components/InstallSwitcher.tsx +0 -53
- package/src/components/SkillCard.tsx +0 -36
- package/src/components/SkillDetailPage.tsx +0 -817
- package/src/components/SkillDiffCard.tsx +0 -485
- package/src/components/SoulCard.tsx +0 -19
- package/src/components/SoulDetailPage.tsx +0 -263
- package/src/components/UserBootstrap.tsx +0 -18
- package/src/components/ui/dropdown-menu.tsx +0 -67
- package/src/components/ui/toggle-group.tsx +0 -35
- package/src/convex/client.ts +0 -3
- package/src/lib/badges.ts +0 -29
- package/src/lib/diffing.test.ts +0 -163
- package/src/lib/diffing.ts +0 -106
- package/src/lib/gravatar.test.ts +0 -9
- package/src/lib/gravatar.ts +0 -158
- package/src/lib/og.test.ts +0 -142
- package/src/lib/og.ts +0 -156
- package/src/lib/publicUser.ts +0 -39
- package/src/lib/roles.ts +0 -19
- package/src/lib/site.test.ts +0 -130
- package/src/lib/site.ts +0 -84
- package/src/lib/theme-transition.test.ts +0 -134
- package/src/lib/theme-transition.ts +0 -134
- package/src/lib/theme.test.tsx +0 -88
- package/src/lib/theme.ts +0 -43
- package/src/lib/uploadFiles.jsdom.test.ts +0 -33
- package/src/lib/uploadFiles.test.ts +0 -123
- package/src/lib/uploadFiles.ts +0 -245
- package/src/lib/uploadUtils.test.ts +0 -78
- package/src/lib/uploadUtils.ts +0 -93
- package/src/lib/useAuthStatus.ts +0 -12
- package/src/lib/utils.test.ts +0 -9
- package/src/lib/utils.ts +0 -6
- package/src/logo.svg +0 -12
- package/src/routeTree.gen.ts +0 -345
- package/src/router.tsx +0 -17
- package/src/routes/$owner/$slug.tsx +0 -55
- package/src/routes/__root.tsx +0 -136
- package/src/routes/admin.tsx +0 -11
- package/src/routes/cli/auth.tsx +0 -168
- package/src/routes/dashboard.tsx +0 -97
- package/src/routes/import.tsx +0 -415
- package/src/routes/index.tsx +0 -252
- package/src/routes/management.tsx +0 -529
- package/src/routes/settings.tsx +0 -203
- package/src/routes/skills/index.tsx +0 -422
- package/src/routes/souls/$slug.tsx +0 -55
- package/src/routes/souls/index.tsx +0 -243
- package/src/routes/stars.tsx +0 -68
- package/src/routes/u/$handle.tsx +0 -307
- package/src/routes/upload/utils.ts +0 -81
- package/src/routes/upload.tsx +0 -499
- package/src/styles.css +0 -2718
- package/tsconfig.json +0 -24
- package/tsconfig.oxlint.json +0 -16
- package/vercel.json +0 -8
- package/vite.config.ts +0 -48
- package/vitest.config.ts +0 -47
- package/vitest.e2e.config.ts +0 -11
- package/vitest.setup.ts +0 -1
- /package/{packages/pilothub/bin → bin}/pilothub.js +0 -0
- /package/{packages/schema/dist → dist/schema}/ark.d.ts +0 -0
- /package/{packages/schema/dist → dist/schema}/ark.js +0 -0
- /package/{packages/schema/dist → dist/schema}/index.d.ts +0 -0
- /package/{packages/schema/dist → dist/schema}/index.js +0 -0
- /package/{packages/schema/dist → dist/schema}/routes.d.ts +0 -0
- /package/{packages/schema/dist → dist/schema}/routes.js +0 -0
- /package/{packages/schema/dist → dist/schema}/textFiles.d.ts +0 -0
- /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
|
-
}
|